05 文件验证与尽早拒绝
1. 文件验证的三个层次
第一层:扩展名
第二层:multipart part header 中的 mimetype
第三层:真实文件内容和解析结果
前两层成本低,可以尽早拒绝明显错误;第三层更可信,但必须至少读取部分内容。
没有任何单一字段能证明文件安全。
2. fileFilter
import multer from "multer";
const allowedTypes = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
const upload = multer({
storage: multer.diskStorage({
destination: "var/uploads/incoming",
}),
fileFilter(_req, file, callback) {
if (!allowedTypes.has(file.mimetype)) {
callback(new Error("只允许 JPEG、PNG 或 WebP"));
return;
}
callback(null, true);
},
});
三种调用:
callback(null, true); // 接受文件
callback(null, false); // 跳过文件,不自动报错
callback(error); // 让上传失败
如果非法类型属于客户端错误,传递明确错误通常比 false 更容易生成可理解响应。
3. fileFilter 在什么阶段执行
典型流程:
Busboy 解析到文件 part
→ 读取该 part 的 headers
→ 得到 fieldname/originalname/mimetype
→ Multer 调用 fileFilter
→ 接受后才交给 storage engine 保存
因此,fileFilter 不需要等完整文件保存后才执行。它可以在根据 part headers 作出判断后阻止 storage engine 保存该文件。
但这不代表“服务器完全没收到任何文件字节”:
- 客户端可能已经在持续发送;
- 操作系统、代理和 Node.js 可能已经缓存部分网络数据;
- multipart 解析器需要继续消费或丢弃请求流中的数据;
- 普通 HTTP 上传没有“先只发文件头,等服务器批准后再发 body”的强制交互协议。
所以 fileFilter 的准确含义是:
在文件完整写入目标存储之前,根据 multipart part metadata 快速决定是否交给 storage engine。
4. 为什么 mimetype 不可信
攻击者可以发送:
Content-Disposition: form-data; name="avatar"; filename="avatar.jpg"
Content-Type: image/jpeg
但 body 实际是:
可执行文件、HTML、脚本或其他内容
file.mimetype 只是客户端 part header 的值。它适合快速白名单,不适合最终内容认证。
5. 扩展名也不可信
malware.exe → avatar.jpg
script.js → image.png
archive.zip → report.pdf
扩展名可以用于改善用户提示,但不能决定安全性。
6. Magic Number
许多格式开头有特征字节:
JPEG:FF D8 FF
PNG:89 50 4E 47 0D 0A 1A 0A
PDF:25 50 44 46
ZIP:50 4B 03 04
可以读取文件开头进行检测。小文件使用 MemoryStorage:
function isPng(buffer: Buffer): boolean {
const signature = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
]);
return (
buffer.length >= signature.length &&
buffer.subarray(0, signature.length).equals(signature)
);
}
router.post(
"/png",
upload.single("image"),
(req, res): void => {
if (req.file === undefined) {
res.status(400).json({ message: "缺少图片" });
return;
}
if (!isPng(req.file.buffer)) {
res.status(400).json({ message: "文件内容不是 PNG" });
return;
}
res.status(201).json({ message: "上传成功" });
},
);
真实项目更适合使用经过维护的文件类型检测库,不要自己实现大量格式解析器。
7. Magic Number 也不是全部
文件签名正确只表示“开头像某种格式”。仍可能存在:
- 损坏文件;
- 多格式 polyglot 文件;
- 图片解码漏洞;
- SVG 脚本;
- ZIP 炸弹;
- Office 宏;
- PDF 嵌入内容;
- 恶意元数据。
高风险场景还要真正解析、解码、转码或病毒扫描。
8. 图片最佳实践
推荐流程:
限制上传字节数
→ MIME 快速白名单
→ 检测真实格式
→ 使用图片库解码
→ 限制宽高和总像素
→ 移除元数据
→ 重新编码成允许格式
→ 保存重新编码后的文件
重新编码比直接保存用户原始图片更容易统一格式和移除部分恶意内容,但图片处理库本身也需要及时更新。
SVG 不应简单视为普通图片。若必须允许,应使用专门安全策略、内容清洗和响应 Header,或者转成位图。
9. limits.fileSize
const upload = multer({
storage,
limits: {
fileSize: 2 * 1024 * 1024,
},
});
fileSize 在流式读取过程中生效。Multer/Busboy 读取到超过限制的位置时产生错误,不需要等完整文件成功保存后再比较最终大小。
但 storage engine 在超限前可能已经写入了部分数据。Multer 内置存储会参与错误清理;自定义 storage engine 必须正确处理截断、错误和 _removeFile()。
10. 多文件的大小限制
limits: {
fileSize: 2 * 1024 * 1024,
files: 5,
}
含义:
- 每个文件最多 2MB;
- 整个请求最多 5 个文件。
它不代表总文件内容最多 2MB,而是理论上最多约 10MB 文件 body,再加 multipart headers 和文本字段。
如果需要严格总上传大小,还应在 Nginx 或请求入口设置整体 body 限制。
11. 其他 limits
const upload = multer({
limits: {
fieldNameSize: 100,
fieldSize: 64 * 1024,
fields: 10,
fileSize: 5 * 1024 * 1024,
files: 3,
parts: 13,
headerPairs: 100,
fieldNestingDepth: 3,
},
});
| 限制 | 保护内容 |
|---|---|
fieldNameSize |
文本字段名长度 |
fieldSize |
单个文本字段大小 |
fields |
非文件字段数量 |
fileSize |
单文件大小 |
files |
文件数量 |
parts |
文件与文本 part 总数 |
headerPairs |
part header 数量 |
fieldNestingDepth |
嵌套字段深度 |
这些限制可以减轻 DoS 风险,应根据接口最小需要配置,而不是一律使用 Infinity。
12. Nginx 层拒绝
client_max_body_size 10m;
它限制整个 HTTP 请求体,超出后请求可以在进入 Express 前被拒绝。
三层职责:
Nginx:整个请求体上限
Multer limits:multipart 结构、文件数量和单文件上限
业务层:文件类型、内容、尺寸和权限
Nginx 不知道“第几个文件不合法”,Multer 也不能代替内容扫描。
13. 能否只通过请求 Header 完全拒绝文件
请求级 Content-Type 可以提前拒绝非 multipart:
if (!req.is("multipart/form-data")) {
res.sendStatus(415);
return;
}
Content-Length 可以用于快速拒绝明显过大的请求,但:
- 可能不存在;
- 客户端声明不能作为唯一计数依据;
- chunked 请求没有普通 Content-Length;
- 它是整个请求大小,不是单文件大小。
文件类型信息位于 multipart part headers 中,服务器必须开始解析 multipart 才能获得。真实内容类型必须读取文件 body。
因此不存在一个既完全不读取文件内容、又能可靠判断真实类型的通用方案。
14. 隔离区设计
推荐状态:
pending
→ scanning
→ ready
→ rejected
上传完成后先进入不可公开访问的隔离区。只有检测完成并更新为 ready 后才能下载或展示。
病毒扫描、视频转码等耗时任务可以异步执行。接口先返回 fileId 和 pending 状态,前端轮询或通过消息获得结果。
15. 练习题
- 使用 fileFilter 根据 mimetype 快速拒绝非图片,并说明它为什么可伪造。
- 对 PNG 实现简单签名检测,再上传一个伪装成 PNG 的文本文件。
- 配置单文件 2MB、最多 5 个文件,并计算理论文件内容上限。
- 增加 fields、parts 和 fieldNestingDepth 限制。
- 设计图片解码、限制像素和重新编码流程。
- 比较 Nginx、Multer 和业务内容检查各自能拒绝什么。
- 设计 pending/scanning/ready/rejected 文件状态流。