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 忽略文档构建产物
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user