Node.js 上传流背压:限制字节只是第一道边界

Node.js 上传流背压:限制字节只是第一道边界原创封面

上传风险从网络流进入进程时就开始

Content-Length 只能作为早期提示,客户端可以省略、伪造或使用分块传输。真正的字节上限必须在请求流进入解析器、解压器和图像库之前累计,并在超过阈值时立即终止整个管线。若先把 body 收集成 Buffer 再校验大小,内存已经被占用;若先解压再计数,压缩炸弹已经把 CPU 与磁盘推到危险区域。入口限制应按路由和身份配置,而不是用一个全站数字覆盖头像与大型附件。

字节只是第一层预算。图片需要限制解码后的像素、帧数与元数据,压缩包需要限制条目数、展开总量和路径,文本解析要限制嵌套深度。总请求时长、空闲读取时间和同用户并发也必须受控。攻击者不一定发送大文件,持续以极低速率占住连接同样会耗尽资源。每项预算都要有明确错误分类和清理路径,使拒绝发生时不会留下半写文件或数据库占位。

pipeline 负责传播背压、错误与销毁

Node.js 可读流在下游处理较慢时需要暂停上游,Writable.write 返回 false 与 drain 构成背压信号。手工监听 data 后无条件写入,等于绕开这套协议,数据会在内存队列积累。优先使用 stream/promises 的 pipeline 把请求、字节计数变换、内容检查和临时文件串起来;任一环节失败时,pipeline 会向相关流传播销毁并让调用者得到拒绝,而不是某个监听器只打印错误后继续返回成功。

自定义 Transform 必须在处理完成后调用 callback,不能在异步扫描尚未结束时提前确认块。变换层只做有限内存工作,若需要聚合文件头,应设置严格最大长度并在识别后继续流式转发。highWaterMark 是缓冲阈值而不是文件上限,调大它可能提高顺序磁盘吞吐,也可能让每条并发上传占用更多内存;应在真实并发和慢下游条件下测量,不把默认值神化。

取消信号要贯穿请求与底层处理器

客户端断开时,服务端应触发 AbortController,使 pipeline、病毒扫描、远端对象存储和后续解析尽快停止。只在路由层 return 不会自动取消已经启动的子任务,它们可能继续占用 CPU、写临时文件,甚至在用户离开后创建数据库记录。每个支持 signal 的 API 都传入同一取消源,不支持取消的库至少要隔离结果,确保迟到回调不能提交公开状态。

取消与系统错误、内容拒绝需要区分。用户断开通常不显示服务端错误 Toast,但日志仍可记录 requestId、接收字节和停止阶段;文件类型不允许属于确定性内容失败;磁盘满或扫描服务不可用属于系统故障,不能把未经扫描的文件当作成功降级。finally 清理应依据本次明确创建的路径列表,不能扫描公共临时目录后批量删除,以免并发请求互相移除文件。

临时文件从命名到发布都遵守不可见原则

每次上传在受限根目录下创建随机请求目录,文件使用独占创建,服务账户之外不可读。原始文件名只作为显示元数据,不能参与服务器路径;先规范化再做前缀检查仍可能遭遇符号链接和竞态,最简单的边界是完全由服务端生成路径且不跟随用户可控链接。临时目录与公开媒体目录分离,验证完成前即使猜到名字也无法通过 Web 路由访问。

提交顺序要说明文件系统与数据库没有共同事务。可以先在临时位置完成全部不可见处理,计算强哈希和最终派生文件,再在数据库事务创建待激活媒体记录;事务成功后使用同卷原子重命名切换文件,最后标记可用。若任一步失败,依据创建清单补偿。另一种先移动再写库的流程也可行,但必须在数据库失败时安全删除本次新文件,并在目标已存在且哈希不同的时候停止,绝不覆盖未知内容。

类型识别不能相信扩展名和请求头

Content-Type 与扩展名由客户端提供,只能用于友好提示。服务端读取有限文件头识别格式,再将识别结果与路由允许列表比较;多态文件、截断头和不支持格式应拒绝。图片库读取 metadata 后先检查宽高、像素乘积、帧数和颜色空间,再执行完整解码与变体生成。SVG、HTML 等主动内容若没有严格业务需要,避免作为普通图片原样公开,否则同源展示可能引入脚本与外部资源风险。

安全检查结果要与具体文件哈希绑定。扫描完成后如果文件仍能被修改,结论已经失效,因此临时文件只允许上传进程写入,后续处理从只读句柄读取。派生 WebP、AVIF 或缩略图同样计算尺寸和哈希,manifest 记录用途与来源。错误体不要回显服务器路径、扫描器内部命令或完整元数据;用户需要的是允许类型、大小上限和可修复操作,诊断细节留在脱敏结构化日志。

并发和磁盘配额是系统级边界

单个请求符合上限不代表系统安全。五十个同时进行的图像转码可能耗尽内存和 CPU,慢磁盘会让每条 pipeline 都保持缓冲。服务端应按进程、用户和处理类型设置并发信号量,把重 CPU 派生工作放入受控工作池或队列。进入队列前仍需完成网络字节限制,不能把未受控请求流长期挂在 Web 进程。队列满时返回明确可重试响应,而不是继续接收后在磁盘满时失败。

磁盘预算包括临时原图、多个变体、失败残留和同卷原子切换所需空间。定期清理任务只删除超过期限且数据库没有活跃引用的请求目录,运行前记录候选并限制批次。磁盘告警要早于文件系统只读或写满,指标至少包含临时字节、最老文件年龄、活跃上传数、转码队列长度和每类拒绝。若清理速度低于流入速度,应限流并定位卡住阶段,而不是扩大保留目录掩盖泄漏。

故障测试组合慢上游、慢下游与恶意内容

集成测试使用真实 HTTP 流分块发送,覆盖缺失和虚假 Content-Length、刚好等于上限、超过一个字节、客户端中断与超时。自定义慢 Writable 或受控磁盘代理验证背压:内存应保持在与 highWaterMark 和并发数相符的范围,而不是随文件总量增长。让字节限制 Transform、类型识别、扫描器和最终移动分别抛错,断言响应分类、流销毁和临时文件清理都正确。

内容样本包括超大尺寸小体积图片、多帧图、损坏文件头、扩展名伪装、压缩路径穿越和大量条目。性能测试逐步增加并发,记录事件循环延迟、RSS、句柄数、磁盘吞吐和队列等待;任何未验证文件可公开、请求取消后仍提交,或不同请求互删文件都应立即失败。测试清理本身也是断言,不能在 afterEach 用全目录删除把产品泄漏掩盖掉。

运行期审计关注资源归零而非只看成功率

每次上传日志使用随机 requestId,记录身份摘要、实际接收字节、识别类型、处理阶段、耗时和最终媒体 id,不记录 Cookie、文件正文或敏感原名。指标把内容拒绝、用户取消、系统依赖失败和内部缺陷分开;如果全部都记作 500,值班人员无法判断是否应扩容、修复数据或调查攻击。成功响应必须发生在业务定义的提交点之后,排队处理则明确返回处理中状态与查询标识。

发布新格式或更换图像库前,用固定语料比较输出尺寸、解码资源与失败分类,先灰度少量流量。保留原图的策略要与隐私和删除需求一致,用户删除应传播到变体、缓存和备份保留流程。定期强杀正在上传和转码的进程,确认启动恢复不会误发布半成品,临时目录能在安全期限后回收。上传系统真正可靠的标志,是各种失败后资源、状态和可见性都回到可解释位置。

框架、代理和进程退出都不能吞掉流的所有权

某些 Web 框架会在路由前自动聚合 body 或解析 multipart,业务代码取得文件时背压已经失效。上传路由必须确认 Nitro、Node 适配器与中间件的真实行为,关闭不需要的全量解析,让原始请求流只有一个消费者。两个库同时监听 data 可能造成缓存、丢块或错误处理竞争,封装层要明确谁负责读取和销毁。

响应只能在 pipeline、校验和定义的提交点之后发送。若 header 已写出才发现磁盘失败,已经无法给客户端可靠状态。异步上传会话可以先返回 session id,但语义只能是“已接收等待处理”,客户端查询 SCANNING、READY 或 REJECTED;公开媒体 URL 只在 READY 产生,不能用乐观成功掩盖后台扫描。

反向代理设置请求体、读取超时和连接上限,靠近网络提前拒绝;应用仍保留实际字节与解码预算,因为本地入口和内部请求可能绕过代理。两层阈值必须对齐并通过公网与本地端口分别冒烟。代理小于产品上限会误拒合法文件,应用没有自己的上限则会把安全完全寄托在单一部署配置。

进程收到终止信号后停止接受新上传,为活跃 pipeline 提供有限排空,超时则传播 AbortSignal 并按精确清单清理。启动恢复只处理带请求 manifest 且超过期限的不可见临时目录,不猜测半成品可以继续发布。没有可验证检查点时,安全默认是保持不可见并要求重新上传,而不是把残留文件移入正式目录。

实现片段

await pipeline(request, byteLimit(maxBytes), temporaryFile)

一手参考资料

评论 · 0

还没有评论留下第一句经过思考的话。

游客评论需审核。注册后可直接公开,无需审核。

SHARE / 分享

分享这篇文章

WECHAT / 微信

用微信扫一扫

在手机微信中打开文章后,再从微信右上角分享给朋友或朋友圈。