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 通过。
This commit is contained in:
@@ -32,13 +32,32 @@ async function sendFile(ctx, app, opts) {
|
|||||||
if (absolutePath) {
|
if (absolutePath) {
|
||||||
absPath = absolutePath;
|
absPath = absolutePath;
|
||||||
} else {
|
} else {
|
||||||
// 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..)
|
// 🔴 路径穿越防护(2026-10-12 修复):改为「危险形态黑名单 + resolve 前缀校验」。
|
||||||
if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) {
|
// 原实现是全 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.status = 400;
|
||||||
ctx.body = ctx.helper.error(400, '非法文件路径');
|
ctx.body = ctx.helper.error(400, '非法文件路径');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
absPath = path.join(app.baseDir, 'app/public', relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
|
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
|
||||||
|
|||||||
Reference in New Issue
Block a user