NestJS 大文件上传实战:从 413 到安全落盘
本文目录
大文件上传不是简单地把控制器里的 memoryStorage 换成 diskStorage。请求会依次经过浏览器、CDN/WAF、反向代理、API 网关、NestJS multipart 解析器、临时文件系统和数据库;任何一层都可能提前拒绝或缓冲整个请求。本文用 NestJS Express + Multer 说明如何定位边界、限制资源并把文件安全地交给后续处理。
先区分 413 与缺少文件
HTTP 413 表示某个接收层认为请求体超过自己的上限。它可能由 CDN、Nginx、API 网关、Express body parser 或 Multer 产生。浏览器收到的状态码本身不能证明请求到达了 NestJS;应结合每一层的状态码、请求 ID 和不含请求正文的结构化日志定位。日志只记录路由模板、请求 ID、状态和字节数,不记录 Authorization、Cookie 或整段 multipart 正文。
反过来,后端说没有文件,并不等于上限太小。常见原因包括字段名不匹配、请求根本不是 multipart、边缘代理读完了正文却没有转发,或客户端手工设置了错误的 Content-Type boundary。浏览器使用 FormData 时不要手写 Content-Type,让浏览器生成 boundary;命令行用 curl -F 'file=@./book.epub',不要用 --data-raw 拼 multipart 文本。
把上限放在整条链路上对齐
假设业务允许单文件最多 200 MiB,应用层应按精确字节数配置 200 * 1024 * 1024。反向代理的请求体上限还要覆盖 multipart 分隔符、每个 part 的头部和少量元数据,例如为这条专用路由留出合理余量;不要因此把全站上限改成无限。CDN/WAF、Nginx、网关和应用各自的限制都要列在配置清单中,变更后逐层验证。
client_max_body_size 220m;
proxy_request_buffering off;
proxy_request_buffering off 让 Nginx 在可行时边收边转发,减少代理临时文件和等待;它不会取消 Nginx 的请求体上限,也不自动让下游应用流式处理。确认后端消费速度、客户端断连时的取消行为、代理超时和临时目录空间,再启用该选项。
实战复盘:约 1.71 MB 就返回 413
一次线上电子书上传排查中,Nginx 日志记录到两次约 1.71 MB 的请求返回 413。根因不是书籍转换器或数据库,而是当时虚拟主机的 client_max_body_size 1m:请求在进入 NestJS 控制器前已被 Nginx 拒绝。Nginx 官方文档说明该指令默认值为 1m,超限会返回 413;因此排查时先看边缘访问/错误日志和命中的 server/location 配置,不要只盯应用日志。
随后专用上传路由的边缘限制调整为 220m,并关闭请求体缓冲;截至 2026-10-16 的只读配置核对仍是这个边界。NestJS 书籍上传拦截器以磁盘暂存接收,单文件上限为 200 * 1024 * 1024 字节。两者解决的是不同层:220 MiB 给 multipart 头部和字段留余量,200 MiB 才是应用接受的单文件最大值。它不代表站点允许无限请求,也不代表所有上传格式最终都会被解析或上架。部署后应对照当前配置再操作,不能把这组数字当成永久常量。
当时的匿名 2 MiB multipart 探测返回 401,说明请求已越过旧的 1 MiB 边缘限制并到达后端鉴权;它只验证了请求到达鉴权层,不验证文件被解析、写入磁盘、保存到数据库或可正常阅读。没有完成真实的已鉴权 200 MiB 端到端上传,因此不能把该探测记为大文件上传验收通过。
后来提供的手工 --data-raw 样例在文件头与下一个 boundary 之间没有书籍字节,本质上是空文件 part,也不能复现大文件请求。验证实际文件时让客户端负责编码 multipart,例如使用 curl -F 'file=@./book.azw3';不要手写 boundary 或把认证令牌粘贴进可留痕的命令和日志。浏览器则传入原生 FormData,不自行设置 Content-Type。
如果通用 API 网关会解析或缓冲请求体,大文件路由可以从边缘层直接转发到内网 NestJS 服务,或由支持流式转发的网关处理。但后端监听仍应限制在回环地址或私网,外部请求必须经过同一 TLS、鉴权、授权、限流与审计边界;不能为了绕开网关就把应用端口公开。
Multer 磁盘暂存与解析限制
NestJS 的 Express 上传拦截器使用 Multer。没有配置 dest 或 storage 时,文件会进入内存并形成 file.buffer;同时到达的多个上传会把文件大小乘以并发数,直接推高堆内存。给大文件显式配置磁盘存储,并把暂存目录放到 web root 之外、受限权限的持久磁盘上。启动时确认目录存在、所有者和权限正确;部署目录替换时不要误删上传卷。
const { randomUUID } = require('node:crypto');
const { diskStorage } = require('multer');
const MAX_FILE_BYTES = 200 * 1024 * 1024;
const uploadOptions = {
storage: diskStorage({
destination: (_request, _file, callback) =>
callback(null, privateStagingDir),
filename: (_request, _file, callback) =>
callback(null, `${randomUUID()}.part`),
}),
limits: {
fileSize: MAX_FILE_BYTES,
files: 1,
fields: 8,
parts: 10,
},
};
将 uploadOptions 传给受保护路由的 FileInterceptor('file', uploadOptions)。limits.fileSize 在 multipart 解析时限制实际读取的文件字节;只在控制器里检查 file.size 太晚,不能替代解析器上限。多个字段也要限制数量、名称长度和总 part 数,避免攻击者用大量小字段消耗 CPU、文件描述符或内存。具体支持的 limits 以项目锁定的 NestJS/Multer 版本为准。
角色和资源权限必须由后端 Guard 验证,不能信任表单里传来的 ownerId、status 或 isApproved。例如管理员上传后是否直接上架,由服务端根据当前账号角色决定;普通读者上传则由服务端强制记为待审。若认证依赖 Cookie,还需要 CSRF 防护;使用 Authorization header 也仍要做好 HTTPS、来源控制和日志脱敏。
校验内容、生成存储名并安全晋级
客户端文件名、扩展名、Content-Type 和长度头都不可信。扩展名和 MIME 可用于尽早给出用户友好的错误,但不能证明内容安全。对业务允许的格式使用维护中的签名/格式解析器读取文件本身;特殊容器格式还要检查内部目录结构、压缩后总量、嵌套深度和解析时间。一个 ZIP 签名只能说明容器类型,不能证明它是合法 EPUB,更不能证明里面没有恶意内容。
原始文件名只作为经过长度限制和输出编码的展示元数据保存,不参与路径拼接。磁盘文件使用服务端随机 ID,存放在非公开目录。验证通过后,从暂存名原子移动到最终随机名;原路径、目标路径应位于同一文件系统,否则 rename 可能失败或退化为复制。不要让用户选择目标路径,也不要让上传目录中的 HTML/脚本被 Web 服务器当成可执行站点内容。
文件晋级与数据库事务不是同一个原子操作。可以先建立 PENDING 记录,再落盘并切换状态;也可以先落盘再写数据库,但必须为数据库写入失败时删除本次生成的文件,并提供定期对账清理孤儿文件的流程。清理任务只能删除确认无人引用、超过保留期的暂存对象,不能靠目录名猜测删除用户文件。
如果确实需要断点续传,不要把一个请求无限延长。先创建绑定当前用户和目标资源的上传会话,服务端生成会话 ID;每个分片限制字节数、数量、总大小和有效期,记录分片偏移/校验和并拒绝重叠或重复覆盖。完成时由服务端核对分片集合、顺序和最终摘要,再在同一私有文件系统上组装并原子晋级;取消和过期会话必须清理暂存占用。若使用对象存储,优先采用其 multipart upload 能力,签名只授予特定对象、短时有效且不可读取已有对象,完成/取消仍由后端校验和审计。
下载也是权限边界
文件读取要重新验证当前账号、资源状态和目录权限,不能因为用户曾经登录或上传过就永久公开。可以使用不泄露磁盘路径的文件 ID,通过受控下载接口执行授权,再用 Content-Disposition: attachment 和 X-Content-Type-Options: nosniff 降低浏览器把主动内容当页面解释的机会。确需 Range/断点下载时,验证每个 Range 都访问同一个授权资源,并限制并发与范围数量;短时签名地址应绑定对象和有效期,不能被当成长期公开 URL。
上传成功只是接收完成,不代表内容已安全可读。电子书封面提取、格式转换、病毒扫描或文档解析放进受限后台队列:设置 CPU/内存/磁盘/时长限制,使用低权限账号和独立工作目录,拒绝符号链接逃逸,失败后清理临时产物。公开下载应走授权后的受控接口或短时签名地址,而不是直接暴露存储目录。
错误映射与排障顺序
| 现象 | 优先检查 |
|---|---|
| 413 且没有应用请求日志 | CDN/WAF/Nginx 的请求体上限和路由命中 |
413 且应用记录 LIMIT_FILE_SIZE | Multer 路由级文件字节上限及业务目标值 |
| 400 提示缺少文件 | multipart boundary、字段名、网关转发正文和解析器配置 |
| 401/403 | 登录状态与当前角色/资源授权;不要为解决上传而跳过 Guard |
| 上传中断后暂存目录持续增长 | 客户端取消、磁盘空间、清理钩子与过期暂存清理指标 |
用户端错误应给出有限且可执行的提示,不要把 Multer 堆栈、服务器绝对路径或临时文件名直接返回。监控按路由统计请求字节、解析耗时、失败层、并发数和可用磁盘;避免把用户名、原始文件名和认证头放进高基数指标或日志。
验收矩阵
- 文件略低于上限成功,超过一个字节得到稳定的 413;客户端提供的 Content-Length 不被当作唯一依据。
- 缺少
file字段、错误 boundary、多个文件、过多字段和空文件分别返回清楚的 4xx。 - 未登录、无角色、越权目录和伪造审批状态均被拒绝;失败请求不会留下公开文件。
- MIME 伪造、扩展名双写、损坏容器、压缩炸弹、特殊路径字符和异常编码不会覆盖已有文件或逃出存储目录。
- 同一账号并发上传达到上限后被排队或限流;磁盘满、进程终止、数据库失败和客户端取消后都能恢复并清理。
- 从公网经过真实 CDN/Nginx 完成一次大文件上传,再核对服务端落盘字节数、数据库状态、签名读取和直接目录访问拒绝。只测 localhost 不能证明生产代理配置正确。
参考资料
- NGINX
client_max_body_size:指令上下文、默认请求体上限与 413 行为。 - NGINX
proxy_request_buffering:请求缓冲开启/关闭时的转发行为。 - NestJS:File upload and streaming:Express/Multer 拦截器、存储方式与上传限制。
- Node.js Streams:背压、
pipeline、错误传播与流清理。 - OWASP File Upload Cheat Sheet:授权、大小/类型验证、私有存储和内容处理风险。
示例展示的是工程边界,不是复制后即适用于任意文件类型的完整服务。应按锁定依赖的版本验证 Multer 错误、磁盘行为和代理路由;对未支持的格式明确拒绝,而不是放宽成任意文件。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。