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 保存该文件。

但这不代表“服务器完全没收到任何文件字节”:

所以 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 也不是全部

文件签名正确只表示“开头像某种格式”。仍可能存在:

高风险场景还要真正解析、解码、转码或病毒扫描。

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,而是理论上最多约 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 可以用于快速拒绝明显过大的请求,但:

文件类型信息位于 multipart part headers 中,服务器必须开始解析 multipart 才能获得。真实内容类型必须读取文件 body。

因此不存在一个既完全不读取文件内容、又能可靠判断真实类型的通用方案。

14. 隔离区设计

推荐状态:

pending
  → scanning
  → ready
  → rejected

上传完成后先进入不可公开访问的隔离区。只有检测完成并更新为 ready 后才能下载或展示。

病毒扫描、视频转码等耗时任务可以异步执行。接口先返回 fileId 和 pending 状态,前端轮询或通过消息获得结果。

15. 练习题

  1. 使用 fileFilter 根据 mimetype 快速拒绝非图片,并说明它为什么可伪造。
  2. 对 PNG 实现简单签名检测,再上传一个伪装成 PNG 的文本文件。
  3. 配置单文件 2MB、最多 5 个文件,并计算理论文件内容上限。
  4. 增加 fields、parts 和 fieldNestingDepth 限制。
  5. 设计图片解码、限制像素和重新编码流程。
  6. 比较 Nginx、Multer 和业务内容检查各自能拒绝什么。
  7. 设计 pending/scanning/ready/rejected 文件状态流。

官方参考