Files
ny-exam-platform/tools/test-upload-guard.js
T
nxxy335 a3be81b261 fix(security): 修复上传名单文件可被 URL 直连访问(nginx 下可 getshell)
严重漏洞:roster_import 使用 $_FILES 的原始扩展名拼 roster_YYYYMMDD_HHMMSS.<ext>
落在 Web 根目录内的 data/uploads/,既无扩展名白名单(可上传 .php),文件名又
可按秒枚举;而 bootstrap 只写了 data/.htaccess,nginx 不读 .htaccess,
生产环境(nginx + PHP-FPM)下可直接远程代码执行。

修复(api.php roster_import 四层防护):
- 扩展名白名单:仅 xlsx / xlsm / csv / txt
- 随机文件名:bin2hex(random_bytes(16)),杜绝枚举
- 解析完成后 @unlink 立即删除,Web 目录下不留存任何上传文件
- 20MB 大小限制 + UPLOAD_ERR 检查

加固(bootstrap.php):data 及 cache/uploads/snapshots 每个目录都写
.htaccess(Deny from all)+ index.html 占位;README 与文档补充 nginx
location ^~ /data/ { deny all; return 404; } 规则。

测试:新增 e2e tools/test-upload-guard.js(11 断言,覆盖 .php/.phtml 被拒、
无残留、合法 csv 导入成功且即删、无可枚举文件名),并入 tests/run-all.sh。
全套 90 项通过。

docs: 新增 VitePress 文档站(18 页)
- 使用指南 12 页:简介/部署/快速上手/系统设置/名单管理/统计口径/试卷设置/
  快照趋势异常/导出打印/数据看板/随机抽取/常见问题
- 安全 2 页:安全总览(含本次漏洞复盘)与部署加固清单(P0-P2)
- 开发 4 页:架构与目录/接口一览/SQLite 存储/测试
- 配图迁移至 docs/public/images/;.gitignore 忽略文档构建产物
2026-09-10 22:42:13 +08:00

261 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;
const DATA = path.join(ROOT, 'data');
const UPLOADS = path.join(DATA, 'uploads');
const throttleFile = path.join(DATA, 'login_fail.json');
// ---------- 备份 / 还原运行时数据 ----------
// 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); }
}
}
// 清空登录限流记录:用覆盖写而不是删除(部分环境下删除会被安全策略拦掉)
function clearThrottle() {
try { fs.writeFileSync(throttleFile, '{}'); } catch (e) {}
try { if (fs.existsSync(throttleFile)) fs.unlinkSync(throttleFile); } catch (e) {}
}
clearThrottle();
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) {}
}
clearThrottle();
// 统一用「覆盖写回 + 尽力删除备份」的方式还原,避免删除被拦导致还原失败
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);
}
// 登录(失败会触发 5 次锁定,所以每次尝试前清掉限流记录)
for (let i = 0; i < 3 && CSRF === ''; i++) {
clearThrottle(); // 上一次尝试若失败会累计限流,这里先清零
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);
})();