Files
ny-exam-platform/tools/test-upload-guard.js
T
nxxy335 804b04de60 移除登录失败限流;修复连接信息被 e2e 串味为 127.0.0.1
- bootstrap.php/login.php:整体移除「5 次失败锁 10 分钟」限流,登录失败仅即时提示原因
- 新增 tools/fix-config.php:一键双写修正连接地址(默认 exam.nxxy335.top),清空账号密码让用户重输真实卷王凭据
- NY_DATA 隔离落地:run-all.sh 给写配置的 e2e 指向临时目录;修 Windows Git Bash /c/... 路径规范化 bug;.ny-test-data 加 .gitignore
- 清理 dead throttle 引用(e2e 脚本 + docs)
- 修 login.php 真实 bug:ny_is_auth() 分支在加载 $cfg 前引用导致 Undefined variable 警告
- 全量测试 116 通过(PHP 44 + e2e 72)
2026-09-11 17:25:22 +08:00

263 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// e2e: 上传文件防护(扩展名白名单 + 随机文件名 + 解析后即删)
// 运行:node tools/test-upload-guard.js (NY_DEBUG=1 可打印调试信息)
//
// 覆盖的漏洞:早期版本按 roster_YYYYMMDD_HHMMSS.<原始扩展名> 存在 data/uploads/,
// 既不做扩展名校验(可上传 .php 造成 RCE),文件名又可枚举;且 nginx 下 .htaccess 无效。
const http = require('http');
const net = require('net');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PHP_PORT = 18081;
const MOCK_PORT = 19911;
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
const DEBUG = !!process.env.NY_DEBUG;
// 数据目录优先读 NY_DATA 环境变量(与 bootstrap.php 一致),让测试整体指向临时目录,
// 不污染生产库(曾因写 mock 配置把生产 base_url 改成 127.0.0.1)。
// Git Bash 风格 /c/Users/... 在 Windows 上 path.resolve 会误判成 C:\c\...,需还原;并把规范化后的
// Windows 绝对路径写回 process.env.NY_DATA,让子进程(PHP 内置服务器)也拿到正确路径。
function resolveDataDir() {
let p = process.env.NY_DATA || path.join(ROOT, 'data');
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
p = path.resolve(p);
process.env.NY_DATA = p;
return p;
}
const DATA = resolveDataDir();
fs.mkdirSync(DATA, { recursive: true });
const UPLOADS = path.join(DATA, 'uploads');
// ---------- 备份 / 还原运行时数据 ----------
// ny.db 必须移走:配置是 DB 优先,留着库里的真实配置会盖掉本测试写入的 config.json。
// roster.json 只能复制不能移走:mock-server 启动时要读它,移走会让 mock 直接崩。
const moved = []; // [路径, 备份路径] —— 移走
const copied = []; // [路径, 备份路径] —— 复制
for (const f of ['ny.db', 'cache/token.json']) {
const p = path.join(DATA, f);
if (fs.existsSync(p)) {
const bak = p + '.nybak';
try { fs.renameSync(p, bak); moved.push([p, bak]); }
catch (e) { console.error(' 备份失败(移走)' + f + ':' + e.message); }
}
}
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
const p = path.join(DATA, f);
if (fs.existsSync(p)) {
const bak = p + '.nybak';
try { fs.copyFileSync(p, bak); copied.push([p, bak]); }
catch (e) { console.error(' 备份失败(复制)' + f + ':' + e.message); }
}
}
const cfgFile = path.join(DATA, 'config.json');
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:' + MOCK_PORT,
api_prefix: '/api',
username: 'mock',
password: 'surveyking',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
include_temp_save: false,
dedupe: true,
name_keywords: '姓名,名字',
full_score: 100,
pass_rate: 60,
layout: 'default',
}, null, 2));
let mock = null, srv = null, cleanedUp = false;
function killAll() {
for (const p of [srv, mock]) {
try { p && p.kill('SIGKILL'); } catch (e) {}
}
}
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
killAll();
// 丢弃本次测试产生的运行时数据,换回备份
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
const p = path.join(DATA, f);
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
}
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
for (const [p, bak] of moved.concat(copied)) {
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
// ---------- 启动依赖服务 ----------
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
mock.on('exit', (code) => { if (DEBUG) console.log(' [debug] mock 退出,code=' + code); });
const phpArgs = INI.split(/\s+/).filter(Boolean)
.concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
/** 轮询直到端口可连接(避免使用固定 sleep 导致的偶发失败) */
function waitPort(port, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
(function attempt() {
const s = net.connect({ host: '127.0.0.1', port });
const done = (ok) => { s.destroy(); resolve(ok); };
s.setTimeout(800, () => done(false));
s.on('connect', () => done(true));
s.on('error', () => {
if (Date.now() > deadline) return done(false);
setTimeout(attempt, 300);
});
s.on('timeout', () => {
if (Date.now() > deadline) return done(false);
setTimeout(attempt, 300);
});
})();
});
}
// ---------- HTTP 辅助 ----------
const cookieJar = [];
let CSRF = '';
function baseHeaders() {
const h = {};
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
return h;
}
function absorb(res) {
for (const sc of res.headers['set-cookie'] || []) {
const pair = sc.split(';')[0];
const name = pair.split('=')[0];
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
}
}
function request(method, urlPath, headers, bodyBuf) {
return new Promise((resolve, reject) => {
const h = Object.assign({}, headers || {});
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
absorb(res);
const raw = Buffer.concat(chunks).toString('utf8');
let j = null;
try { j = JSON.parse(raw); } catch (e) {}
resolve({ status: res.statusCode, body: j, raw });
});
});
r.on('error', reject);
if (bodyBuf) r.write(bodyBuf);
r.end();
});
}
const get = (p) => request('GET', p, baseHeaders());
function formPost(p, fields) {
const body = Buffer.from(new URLSearchParams(fields).toString());
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
}
function upload(urlPath, fields, file) {
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
const parts = [];
for (const [k, v] of Object.entries(fields)) {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
}
parts.push(Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\n` +
'Content-Type: application/octet-stream\r\n\r\n'));
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
const body = Buffer.concat(parts);
return request('POST', urlPath,
Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), body);
}
function listUploads() {
if (!fs.existsSync(UPLOADS)) return [];
return fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html');
}
// ---------- 断言 ----------
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
(async () => {
const mockUp = await waitPort(MOCK_PORT, 15000);
const phpUp = await waitPort(PHP_PORT, 15000);
chk('mock 卷王服务已就绪', mockUp);
chk('PHP 内置服务器已就绪', phpUp);
if (!mockUp || !phpUp) {
console.log(' 服务未就绪,终止测试');
cleanup();
process.exit(1);
}
// 登录(最多重试 3 次)
for (let i = 0; i < 3 && CSRF === ''; i++) {
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
const ac = await get('/api.php?action=auth_check');
if (DEBUG) {
const m = (lg.raw || '').match(/登录失败[\s\S]{0,160}?<\/div>|尝试次数[^<]*/);
console.log(' [debug] login ' + lg.status + ' auth=' + JSON.stringify(ac.body && ac.body.data) +
' err=' + (m ? m[0].replace(/\s+/g, ' ').slice(0, 160) : '(none)'));
}
if (ac.body && ac.body.data && ac.body.data.auth === true) {
const cg = await get('/api.php?action=config_get');
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
}
}
chk('已登录并取到 CSRF token', CSRF !== '');
if (CSRF === '') {
console.log(' 登录失败,终止测试');
cleanup();
process.exit(1);
}
console.log('\n--- 1) 危险扩展名必须被拒绝 ---');
const evil = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'shell.php', content: '<?php system($_GET["c"]); ?>',
});
// 失败响应字段为 msg(ny_fail 输出 {"ok":false,"msg":...})
const evilMsg = (evil.body && (evil.body.error || evil.body.msg)) || '';
chk('上传 .php 被拒绝', !!(evil.body && evil.body.ok === false), evilMsg || evil.raw.slice(0, 120));
chk('拒绝原因是扩展名白名单', /仅支持/.test(evilMsg), evilMsg);
const phtml = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'a.phtml', content: 'x',
});
chk('上传 .phtml 被拒绝', !!(phtml.body && phtml.body.ok === false));
console.log('\n--- 2) 被拒绝后不留残留文件 ---');
chk('data/uploads 无残留', listUploads().length === 0, JSON.stringify(listUploads()));
console.log('\n--- 3) 合法 csv 正常导入且文件即删 ---');
const csv = '学号,姓名,班级\n90001,测试甲,1班\n90002,测试乙,1班\n';
const good = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
});
chk('合法 .csv 导入成功', !!(good.body && good.body.ok === true),
(good.body && (good.body.error || good.body.msg)) || '');
chk('导入人数为 2', !!(good.body && good.body.data && good.body.data.imported === 2),
good.body ? String(good.body.data && good.body.data.imported) : '');
chk('导入后 data/uploads 已清空', listUploads().length === 0, JSON.stringify(listUploads()));
console.log('\n--- 4) 文件名不可枚举 ---');
const leaked = listUploads().filter(f => /^roster_\d{8}_\d{6}\./.test(f));
chk('不存在可枚举的 roster_时间戳 文件', leaked.length === 0, JSON.stringify(leaked));
console.log('');
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
cleanup();
process.exit(fail === 0 ? 0 : 1);
})();