服务端文件上传校验从 multipart 请求抵达那一刻开始,而此时唯一值得信任的只有字节本身。Content-Type 和文件名都来自浏览器,因此应当把它们当作「声明」而非「事实」。如果你是从浏览器端的相关文章——上传前生成图片缩略图 和 使用 Canvas 将图片转为 Base64——一路读到这里的,那么本文正是这条流水线的接收端。
流水线的两端承担不同职责。浏览器端的缩放与压缩是对用户体验的照顾;服务端校验才是真正阻止 2 GB 请求体、或阻止一个披着 .jpg 扩展名的脚本的防线。本文将逐一介绍 Node 图片上传接口所需的五道控制措施:文件签名检查、分层大小限制、服务端生成文件名、重新编码,以及安全地对外提供文件。
要点速览
- multipart 上传中的 Content-Type 头和文件名都由客户端设定,因此只校验其中任一项的服务端,验证的是攻击者对文件的声明,而不是文件本身。
- 签名检查应在内存中读取文件的起始字节,在任何数据落盘之前完成,并与该接口明确接受的格式进行比对。
- 大小限制应首先设在代理层,其次是 Multer 的
limits选项,最后才是应用代码;Multer 的fileSize默认值为 Infinity,不设置就等于没有限制。 - 使用 sharp 重新编码,才能让签名伪造彻底失效:输出是一个全新的文件,EXIF 元数据(包括手机照片中的 GPS 坐标)不会留存下来。
- 对外提供已存储的图片时,Content-Type 应取自你自己的校验记录,并附加
X-Content-Type-Options: nosniff,同时把存储路径放在 web 根目录之外。
为什么 MIME 类型和扩展名检查会失效?
检查 file.mimetype 或文件扩展名并不能校验文件,因为这两个值都由客户端提供。本博客此前的两篇文章曾给出这样的建议:Multer NPM: File Upload in Node.js 展示了一个 fileFilter,只要 mimetype 出现在允许列表中就放行;Safe User Input Handling in Node.js 则建议读者在解析器层面校验 MIME 类型。这两种检查作为低成本的早期拒绝手段值得保留,但都不能算安全控制。
伪造只需要一行命令:
curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload
Multer 会把这个 type 值直接拷贝到 req.file.mimetype 中。你的过滤器看到的是 image/jpeg,而请求体其实是一个 shell 脚本。OWASP 文件上传备忘单 明确指出,校验必须基于文件内容,而不是客户端提供的元数据。
文件上传校验始于文件签名
在任何数据写入磁盘之前,在内存中读取缓冲区的起始字节,并与该接口明确接受的格式进行比对。这句话的两个部分都很重要。教程中常见的做法是等文件已经落到 uploads 目录之后再校验——这意味着超大或恶意的载荷在检查运行之前就已经造成了破坏。而且它们通常只要匹配上任意一种已知签名就放行,于是一个 PDF 就能顺利通过头像上传接口。一个能识别 PDF 的头像接口,这是校验缺陷,不是功能特性。
签名本身:JPEG 以 FF D8 FF 开头;PNG 以 PNG 规范 中定义的完整 8 字节序列开头;而根据 WebP 容器规范,WebP 要求偏移 0 处为 RIFF,同时第 8 到 11 字节为 WEBP。第 4 到 7 字节是 RIFF 分块大小,这正是 WebP 检查必须跳过它们的原因,也是为什么简单读取 4 个字节既处理不好 PNG 也处理不好 WebP。
const SIGNATURES = {
"image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
"image/png": (b) =>
b.length >= 8 &&
b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
"image/webp": (b) =>
b.length >= 12 &&
b.subarray(0, 4).toString("ascii") === "RIFF" &&
b.subarray(8, 12).toString("ascii") === "WEBP",
};
// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}
需要注意一点:只要在任意载荷前面拼上正确的字节,签名就能在几秒内被伪造出来。file-type 包 在自己的 README 中也是这么说的——匹配 magic number 只是关于格式的一条线索,而非证明。请把签名检查当成一道快速、廉价的过滤器。真正的安全边界在后面两节。
上传大小限制应该放在哪一层?
大小限制应首先设在代理层,其次是框架层,最后才是应用代码。因为在请求体已经被缓冲之后才运行的检查,只能在完整载荷已经消耗了你的内存和带宽之后才拒绝上传。在 nginx 中,client_max_body_size 默认为 1 MB,会在你的进程看到请求之前就以 413 响应超大请求:
client_max_body_size 5m;
框架层是 Multer(2.x),其中 limits.fileSize 默认为 Infinity,不设置就等于没有限制:
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});
达到上限时,Multer 会抛出 LIMIT_FILE_SIZE 错误。在 Fastify 中,@fastify/multipart 采取了相反的立场,将 fileSize 默认设为 1 MiB,因此安全行为是默认的,而不需要显式开启。上传流程的会话回放能让「跳过代理层」的代价以 UX 缺陷的形式直观呈现出来:进度条走到 100%,然后才出现拒绝提示——因为整个载荷必须先抵达,应用层的检查才能运行。
丢弃客户端提供的文件名
绝不要让客户端的文件名接触你的文件系统。用 crypto.randomUUID() 加上你自己校验得出的扩展名,生成属于你的文件名:
const storedName = `${crypto.randomUUID()}.jpg`;
从路径穿越序列到为什么 path.normalize 不构成防御,相关推理在 Node.js 中防范路径穿越攻击 中有详细讨论;而「服务端生成文件名」这一做法直接让整类攻击无从下手。
重新编码图片,不要存储原始字节
重新编码是让签名伪造彻底失效的关键控制。sharp(0.35.x)会解码像素并写出一个全新的文件,所以无论原文件前面拼接了什么、后面追加了什么、内部藏了什么,都不会留存下来。它同时也堵住了一个隐私泄露口:手机照片的 EXIF 数据中通常带有 GPS 坐标,存储原始字节就意味着把用户的位置信息一并公开了。sharp 输出文档 明确说明了默认行为:除非你用 keepExif() 或 withMetadata() 主动要求保留,输入文件的元数据一概不会进入输出。方向标记(orientation flag)也在被剥离之列,所以要先用 .autoOrient() 自动纠正方向,否则手机照片会横过来:
let clean;
try {
clean = await sharp(req.file.buffer)
.autoOrient() // apply EXIF orientation before metadata is stripped
.jpeg({ quality: 85 })
.toBuffer();
} catch {
return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}
一个通过了签名检查却无法解码的缓冲区,说明它在格式上撒了谎。这正说明检查起作用了。
提供你校验过的内容,而非你收到的内容
对外提供已存储的图片时,Content-Type 要取自你的校验记录,绝不能取自客户端发送的任何内容;加上 X-Content-Type-Options: nosniff,让浏览器无法自行猜测类型;并把文件存放在 web 根目录之外,确保任何上传内容都不可能被直接执行或渲染:
app.get("/images/:id", async (req, res) => {
const record = await getImageRecord(req.params.id); // contentType saved at validation time
if (!record) return res.sendStatus(404);
res.setHeader("Content-Type", record.contentType);
res.setHeader("X-Content-Type-Options", "nosniff");
res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});
几个相关话题,各用一句话说明:
- 病毒扫描(ClamAV 或云端等效服务)在你接受任意文档时才重要,对重新编码过的图片则意义不大。
- 如果你接受压缩包,务必在解压前检查解压后的大小;zip 炸弹就是体积很小但会膨胀到极大的文件。
- SVG 是可以携带脚本的 XML 文档,因此应从图片接口中完全排除。
- 预签名 URL(presigned URL)架构会直接上传到对象存储,这只是把整条流水线搬到上传后的处理步骤中,而不是取消它。
小结
这五道控制构成一条流水线:在代理层按大小拒绝,在内存中依据该接口的允许列表检查签名,用 sharp 重新编码,以你生成的文件名存储,并用你自己控制的响应头对外提供。
| 控制措施 | 运行位置 | 阻止的问题 |
|---|---|---|
| 大小限制 | 优先代理层,其次 Multer limits,最后应用代码 | 超大请求体消耗内存与带宽 |
| 签名检查 | 内存中,任何数据落盘之前 | 与该接口允许列表不匹配的字节内容 |
| 用 sharp 重新编码 | 校验之后,存储之前 | 伪造签名、隐藏载荷、EXIF GPS 数据 |
| 服务端生成文件名 | 存储时 | 通过客户端文件名实施的路径穿越 |
| 经校验的响应头 | 每次读取时 | MIME 嗅探,以及从 web 根目录执行文件 |
签名检查提供低成本过滤;重新编码才是即便签名被伪造也依然成立的边界。可以先从检查你自己的 Multer 配置开始:如果 limits.fileSize 未设置,那么该接口目前接受任意大小的文件。
常见问题
file-type 这个 npm 包能替代手写的签名检查吗?
它能替代字节比对,但替代不了安全模型。file-type 读取的是同样的 magic number,而它的 README 对这能带来什么说得很直白:匹配只是一条线索,既不能确定文件真的属于该类型,也不能确定它格式良好。你仍然需要在其上叠加一份针对具体接口的允许列表,因为它能识别数百种格式;而重新编码依然是真正的边界。另外注意该包仅支持 ESM,因此 CommonJS 项目需要使用动态 import 或 load-esm 之类的变通方案。
Multer 的 fileSize 限制能阻止客户端继续发送文件的剩余部分吗?
并不可靠。当达到 limits.fileSize 时,Multer 会停止缓冲并抛出 LIMIT_FILE_SIZE 错误,这能保护你的进程免于无上限的内存占用,但文档中没有任何内容保证网络传输会被取消,而且该错误可能要等到所有字节都抵达之后才浮现。真正保护带宽的是代理层的上限,例如 nginx 的 client_max_body_size,这也正是限制应首先设在代理层的原因。
当客户端使用预签名 URL 直接上传到对象存储时,该如何校验上传内容?
校验只是转移到上传后的步骤,并没有消失。客户端上传到一个不对外提供服务的隔离区(quarantine bucket 或前缀),随后由后台 worker 或存储触发的函数下载该对象,执行同样的签名检查和 sharp 重新编码,并以生成的文件名把干净的输出写入公开位置。未通过校验的对象被删除,隔离位置永不暴露给浏览器。
用 sharp 重新编码会改变图片的颜色吗?
对广色域图片有可能。如果不做处理,sharp 输出的是不附带任何配置文件的 sRGB 图像——因为剥离元数据时会连内嵌的 ICC 配置文件一起去掉,所以以 Display P3 或 Adobe RGB 创作的照片可能出现轻微色偏。要在不重新引入 EXIF 数据的前提下保留颜色,可在编码前调用 keepIccProfile()。keepMetadata() 也会保留配置文件,但会把所有内容一并带回,包括剥离操作本来在为用户屏蔽的 GPS 坐标。