'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(/^\/+/, ''); 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 造成双重编码。 ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`); ctx.set('X-Accel-Redirect', internalPath); 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 };