Files
competition-website/app/utils/sendFile.js
qubiaobiao c94d8fe3ff fix(download): sendFile 路径校验支持中文文件名(修复 400 非法文件路径)
根因:app/utils/sendFile.js:36 的路径穿越防护用全 ASCII 白名单
/^[A-Za-z0-9_.\/-]+$/,把合法中文文件名(如 测试.xlsx)误判为非法
路径 ⇒ 400「非法文件路径」。该正则与同文件 :88 的 RFC 5987
(filename*=UTF-8''…)中文名支持自相矛盾 —— 中文名本是设计内支持。

影响面:sendFile 是统一文件出口,materials(备赛资料,磁盘保留原始
中文/全角名)/ exam(Word 模板)/ admin(作品·导出)三通道,其中
materials 重度受影响(选手侧直接可见);works 通道磁盘名 ASCII 化
(work_<ts>_<rand>.<ext>)不受影响。

修法:把安全判据从「字符集白名单」换成「危险形态黑名单(控制字符/
反斜杠/绝对路径/.. 段)+ path.resolve 归一化后前缀校验(必须落在
app/public 内)」双层防护,不误伤 Unicode,且防穿越更硬。

验证:端到端(真后端 7001 + 真 DB)PASS=9/FAIL=0(中文名 200、
字节==磁盘 size、X-File-Size 哨兵一致);sendFile 守卫判别力自证
PASS=9/FAIL=0(5 类危险形态全 400、3 类合法 Unicode 放行);
既有门禁 verify_upload_download.py 16/16 通过。
2026-10-12 09:28:44 +08:00

129 lines
6.9 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.
'use strict';
const path = require('path');
const fs = require('fs');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
/**
* 统一文件发送出口(X-Accel-Redirect 根治方案 + 完整性哨兵)。
*
* 所有「把磁盘文件交给调用方」的通道都必须经此函数,杜绝同源漏网。
*
* 2026-08-12 同源扩大化测试审计「缺口9」修复:materials.js(备赛资料) / exam.js(Word 模板)
* / admin.js(batchDownload ZIP 导出) 此前各自直写 fs.createReadStream,缺 X-File-Size 哨兵头,
* 导致前端 assertRealFile 的大小完整性校验收不到哨兵而失效(truncate / nginx fallback 替换
* 无法被前端侦测)。重构进本函数后,未来任意通道的逻辑升级都会自动同步。
*
* @param {object} ctx Egg 上下文
* @param {object} app Egg app(读 config.xAccelRedirect / baseDir)
* @param {object} opts
* - relativePath {string} 相对 app/public 的路径(如 works/benke/x.docx);X-Accel 用此拼内部路径
* - absolutePath {string} 绝对路径(如临时 zip);提供则忽略 relativePath,且强制 Node 流
* - fileName {string} 原始文件名(Content-Disposition)
* - contentType {string} MIME 类型
* - useXAccel {boolean} 是否允许 X-Accel 卸载(默认跟随 cfg.enabled;临时文件务必 false)
*/
async function sendFile(ctx, app, opts) {
const { absolutePath, relativePath, fileName, contentType, useXAccel } = opts;
const cfg = (app.config && app.config.xAccelRedirect) || { enabled: false, internalPrefix: '/internal-downloads/' };
// 路径解析
let absPath;
if (absolutePath) {
absPath = absolutePath;
} else {
// 🔴 路径穿越防护(2026-10-12 修复):改为「危险形态黑名单 + resolve 前缀校验」。
// 原实现是全 ASCII 白名单 /^[A-Za-z0-9_.\/-]+$/,会把合法中文文件名(如 `测试.xlsx`)
// 误判为非法路径 ⇒ materials(资料)/ exam(模板)/ admin(作品·导出)三个下载通道
// 全部对中文名返回 400「非法文件路径」(生产实测 bug)。中文文件名是设计内支持的
// (见下方 RFC 5987 分支),故白名单口径错误。
// ① 形态拦截:控制字符/NUL · 反斜杠 · 绝对路径(/ 开头或盘符) · `..` 路径段
const unsafeShape =
!relativePath ||
/[\u0000-\u001f\u007f\\]/.test(relativePath) ||
relativePath.startsWith('/') ||
/^[A-Za-z]:/.test(relativePath) ||
relativePath.split('/').includes('..');
if (unsafeShape) {
ctx.status = 400;
ctx.body = ctx.helper.error(400, '非法文件路径');
return;
}
// ② 归一化后前缀校验(最硬兜底):解析结果必须仍落在 app/public 之内,
// 即使 ① 被绕过也逃不出根目录(比字符白名单更可靠,且不误伤 Unicode 文件名)。
const publicRoot = path.resolve(app.baseDir, 'app/public');
absPath = path.resolve(publicRoot, relativePath);
if (absPath !== publicRoot && !absPath.startsWith(publicRoot + path.sep)) {
ctx.status = 400;
ctx.body = ctx.helper.error(400, '非法文件路径');
return;
}
}
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
// 发头之前先确认磁盘上真有这个文件,缺失一律回干净 404(业务错误信封),
// 绝不把「下载」交给一个不存在的路径去碰运气。必须在 acquireFileIo 之前,避免 404 还配额。
let fileStat;
try {
fileStat = fs.statSync(absPath);
} catch (e) {
ctx.logger.error(`[sendFile] 文件缺失,拒绝下载 abs=${absPath}:${e && e.message}`);
ctx.status = 200; // 业务错误统一 200 + 信封,前端 safeDownload / fetchBlobGuarded 会识别并弹错
ctx.body = ctx.helper.error(404, `文件不存在(期望路径: ${relativePath || absolutePath})`);
return;
}
if (!fileStat.isFile()) {
ctx.body = ctx.helper.error(400, '目标不是一个有效文件');
return;
}
// 🔴 文件 IO 并发护栏(与上传通道共享同一全局配额):把「同时进行的文件流」限制在上限,
// 防止单 worker 在极端并发下被海量文件流压垮。配额必须在「打开/持有流」之前获取。
let released = false;
const rel = () => { if (!released) { released = true; releaseFileIo(); } };
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,下载通道暂满,请稍后重试');
return;
}
// X-Accel 决策:absolutePath(临时文件)强制 Node 流,避免「发头即 unlink」竞态损坏;
// 即使调用方误传 useXAccel:true + absolutePath,也因无 relativePath 拼内部路径而降级为 Node 流。
const xAccel = (useXAccel === undefined ? cfg.enabled : useXAccel) && !!relativePath;
if (xAccel) {
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
ctx.set('Content-Type', contentType);
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。
// nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。
ctx.set('X-File-Size', String(fileStat.size));
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
// 切勿再包 encodeURIComponent 造成双重编码。
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
ctx.set('X-Accel-Redirect', internalPath);
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
ctx.status = 200;
ctx.body = null;
rel(); // X-Accel 路径无 Node 文件流,立即释放配额
} else {
// 开发回退 / 临时文件:Node 直接流式(与改造前行为一致)。配额须随流「生命周期」释放,
// 不能放在方法返回时的 finally —— 流的读取发生在控制器返回之后。
ctx.attachment(fileName);
ctx.set('Content-Type', contentType);
// 与 X-Accel 分支保持一致,前端可统一按 X-File-Size 校验完整性
ctx.set('X-File-Size', String(fileStat.size));
const stream = fs.createReadStream(absPath);
stream.on('end', rel);
stream.on('close', rel);
stream.on('error', rel);
ctx.body = stream;
}
}
module.exports = { sendFile };