From c94d8fe3ff263651f71ffc95968aa342cf04b66b Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Mon, 12 Oct 2026 09:28:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20sendFile=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=94=AF=E6=8C=81=E4=B8=AD=E6=96=87=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=90=8D=EF=BC=88=E4=BF=AE=E5=A4=8D=20400=20=E9=9D=9E?= =?UTF-8?q?=E6=B3=95=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: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__.)不受影响。 修法:把安全判据从「字符集白名单」换成「危险形态黑名单(控制字符/ 反斜杠/绝对路径/.. 段)+ 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 通过。 --- app/utils/sendFile.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/app/utils/sendFile.js b/app/utils/sendFile.js index b59264a..a9cb0d9 100644 --- a/app/utils/sendFile.js +++ b/app/utils/sendFile.js @@ -32,13 +32,32 @@ async function sendFile(ctx, app, opts) { if (absolutePath) { absPath = absolutePath; } else { - // 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..) - if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) { + // 🔴 路径穿越防护(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; } - absPath = path.join(app.baseDir, 'app/public', relativePath); } // 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)