Files
competition-website/app/utils/sendFile.js
qubiaobiao 3b4d422e36 fix(download): X-Accel-Redirect 头值须 URL 编码(修复中文名下载 500)
承接 c94d8fe:上一提交把中文名从 400「非法文件路径」放行后,暴露出
同一族 bug 的第二处漏网 —— sendFile.js:98 把含中文的 relativePath
直接拼进 X-Accel-Redirect 头值,Node res.setHeader 抛
ERR_INVALID_CHAR ⇒ 500(生产实测,common-error.log 为铁证)。

该分支仅在 NODE_ENV=production(X-Accel 开启)下命中,本地 dev 走
Node 流分支 ⇒ 永远测不出,典型「本地绿、生产红」陷阱。

修法:内部路径逐段 encodeURIComponent(保留 / 分隔符)得到纯 ASCII
的 internalUri;nginx 按解码后 URI 匹配 internal location,alias 落到
磁盘真实中文文件名。

验证:X-Accel 头编码判别力自证 PASS=7/FAIL=0(旧写法必抛
ERR_INVALID_CHAR;新写法头全 ASCII、逐段解码 == 原路径);
经 nginx 生产端到端 PASS=7/FAIL=0(中文名 200、bytes==磁盘 size、
下载内容 sha256 == 源文件 sha256、RFC5987 头正确);
直连 8023 对照 204/0 字节(反证字节确由 nginx 吐出)。
2026-10-12 09:34:32 +08:00

135 lines
7.5 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(/^\/+/, '');
// 🔴 X-Accel-Redirect 是 HTTP 头值,只允许 ASCII:中文/全角文件名必须**逐段 URL 编码**
// (保留 `/` 分隔符),否则 Node 的 res.setHeader 抛 ERR_INVALID_CHAR ⇒ 500。
// 2026-10-12 生产实测:修复 :36 校验放行中文名后,本行成为新的失败点(400 → 500)。
// nginx 按**解码后**的 URI 匹配 internal location,再由 alias 落到磁盘真实中文文件名。
const internalUri = '/' + internalPath.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
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)}`);
// ⚠️ 必须用编码后的 internalUri(纯 ASCII),不可直接用含中文的 internalPath。
ctx.set('X-Accel-Redirect', internalUri);
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 };