Files
competition-website/app/utils/sendFile.js
qubiaobiao cf91f6ba34 fix(download): 哨兵旁路改走 Content-Disposition 的 size= 扩展参数(R103 收口)
背景(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 通道。
2026-10-12 11:05:47 +08:00

150 lines
8.9 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 与哨兵,不一致即拒落盘。
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 };