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 生产事故根因)