背景(R103-3 生产实测已定因):
X-Accel-Redirect 分支下真正吐字节的是 nginx,Node 侧无从得知 nginx 是否吐对
了内容(内部 location 缺失时会吐前端 index.html)。原实现靠自定义响应头
X-File-Size 把「服务端 stat 的真实字节数」透出给前端比对,但双通道对照实测
(.workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js)证伪了原注释所称
「nginx 会保留上游自定义响应头」:
· 直连后端 :8023 ⇒ 204 空体,响应带 x-file-size: 19009
· 经 nginx :80 ⇒ 200 / 19009 字节(与磁盘逐字节一致),而 x-file-size **缺失**
· 对照 /api/health 经 nginx ⇒ 自定义头 x-readtime **保留**
⇒ 丢弃只发生在 X-Accel 内部重定向这一条路径。
影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。
修法:
把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`(实测
Content-Disposition 能穿透 X-Accel),前端按 x-file-size → CD size= 顺序回落。
两通道同源同值;任一缺失只降级不误伤。Node 流分支(absolutePath)不加旁路 ——
其响应走 nginx 常规转发,自定义头本就保留。
验证(2026-10-12 生产部署后实测):
· 本地判别力自证 verify_cd_size_channel.js:pass=12 fail=0
(含 C1 变异测试:修复前旧 CD 形态解不出 ⇒ 判据确判别力)
· 生产判决点 r106_prod_sentinel.js:pass=11 fail=0
ARM A(直连) CD 含 size=19009;ARM B(经 nginx) CD 的 size=19009 穿透成功;
B2 端到端 sha256 与磁盘逐字节一致;C1 对照组佐证丢弃仅限 X-Accel 通道。
150 lines
8.9 KiB
JavaScript
150 lines
8.9 KiB
JavaScript
'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 与哨兵,不一致即拒落盘。
|
||
ctx.set('X-File-Size', String(fileStat.size));
|
||
// 🔴🔴 2026-10-12(R103)生产实测修正 —— 原注释「nginx 的 X-Accel-Redirect 会保留上游自定义
|
||
// 响应头,故此头可穿透到浏览器」**不成立**,已证伪:
|
||
// 双通道对照实测(探针 .workbuddy/probes/2026-10-12-r103/prod_xfs_passthrough.js):
|
||
// · 直连后端 :8023 ⇒ 204 空体,但响应带 `x-file-size: 19009`(后端确实发到了线上)
|
||
// · 经 nginx :80 ⇒ 200 / 19009 字节(sha256 与磁盘逐字节一致,确由 nginx 吐出),
|
||
// 而 **`x-file-size` 缺失**
|
||
// · 对照 /api/health 经 nginx ⇒ 自定义头 `x-readtime` **保留**
|
||
// ⇒ 丢弃只发生在 **X-Accel 内部重定向**这条路径(非「nginx 一概丢自定义头」)。
|
||
// 影响:该分支下前端哨兵空转(下载完整性校验被静默跳过)。
|
||
// 修法:改走「nginx 会保留的通道」—— Content-Disposition 实测能穿透(ARM B 仍在),
|
||
// 故把同一份字节数写成 RFC 6266 §4.1 的 disposition 扩展参数 `size=`。
|
||
// 前端按 `x-file-size` → CD `size=` 顺序回落(见 utils/download.js readDeclaredSize)。
|
||
// 两通道同源同值;任一缺失只降级不误伤。
|
||
// ⚠️ Node 流分支(absolutePath)无需此旁路:其响应走 nginx 常规转发,自定义头本就保留。
|
||
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
|
||
// 切勿再包 encodeURIComponent 造成双重编码。
|
||
// `size=` 追加在 filename* 之后:浏览器忽略未知 disposition 参数,不影响下载行为。
|
||
ctx.set('Content-Disposition',
|
||
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}; size=${fileStat.size}`);
|
||
// ⚠️ 必须用编码后的 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 };
|