// 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: '', }); // 失败响应字段为 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); })();