16.02-文件类型校验

要点

  • 文件扩展名是文件名的一部分,客户端可以随意修改,不能作为类型判断依据
  • HTTP 请求里的 Content-Type 头同样是客户端提供的,可以被伪造
  • 判断文件真实类型需要读取文件内容的前几个字节,对比已知的 magic bytes 签名
  • file-type 库封装了常见格式的魔数对照表,可以直接在 Hono 中间件里调用
  • 白名单策略比黑名单更安全:只放行业务需要的类型,其余一律拒绝
  • AI 场景下需要支持的格式更多(PDF、DOCX、音频),但白名单原则不变
  • 校验失败的错误响应需要给出明确的错误码和原因,方便客户端处理

1. 扩展名为什么不能信

最直接的想法是看文件名叫什么。avatar.png 就认为是图片,report.pdf 就认为是 PDF。

但这个信息完全来自客户端。用户上传时把文件叫什么都行——把 .exe 改成 .png,只是改了文件名,文件内容不会变。

如果服务端只根据扩展名判断类型,然后做后续处理(存到公共目录、返回给其他用户、送进解析器),攻击者就可以构造一个恶意文件,后缀写 .png,实际内容是 JavaScript 脚本或 shell 代码。服务端收到后当图片处理,存进公开目录,其他用户访问时浏览器执行了里面的脚本。

这不是假设的场景。文件上传漏洞里,绕过扩展名检查是最经典的攻击方式之一。

第一条原则:永远不要只依赖扩展名来判断文件类型。

扩展名可以做第一层快速过滤——一个 .xyz 后缀的文件确实不太可能是合法输入。但它不能替代对文件内容的校验。

2. MIME 类型校验的局限

既然扩展名不可靠,很多人会想到 HTTP 请求的 Content-Type 头。上传文件时,multipart/form-data 里每个文件 part 都带一个 Content-Type 字段:

Content-Disposition: form-data; name="file"; filename="photo.png"
Content-Type: image/png

这个值看起来专业一些,但本质上也是客户端填写的。浏览器或 curl 这类工具通常会根据扩展名或文件内容来猜测 MIME 类型,但攻击者可以用任何工具直接构造请求,把 Content-Type 设成任意值。

也就是说:

  1. 一个 .exe 文件可以把 Content-Type 设成 image/png
  2. 一个合法的 PNG 文件也可以把 Content-Type 设成 application/octet-stream
  3. 攻击者可以随意填写任何字符串

Content-Type 不是文件的属性,是请求的元数据。填什么完全取决于发送方。

它能当辅助参考——比如扩展名说 .pngContent-Typeapplication/pdf,这种不一致本身就是一个值得记录或拒绝的信号。但它不能单独作为类型判断的依据。

3. Magic Bytes:文件内容里的指纹

判断文件真实类型,需要看文件内容本身。

大多数文件格式在二进制头部有一段固定的字节序列,叫做「魔数」(magic bytes)。这是文件格式规范的一部分,和文件名、Content-Type 无关,写在文件实际数据里。

几个常见格式的魔数:

格式魔数(十六进制)对应 ASCII
JPEGFF D8 FF
PNG89 50 4E 47 0D 0A 1A 0A.PNG....
GIF47 49 46 38GIF8
PDF25 50 44 46%PDF
ZIP50 4B 03 04PK..
MP3FF FB49 44 33ID3 或帧同步
WAV52 49 46 46 ... 57 41 56 45RIFF....WAVE

一个合法的 PNG 文件,头部一定是 89 50 4E 47。不管它叫 avatar.png 还是 hack.exe,不管 Content-Type 填的是 image/png 还是 application/pdf,文件头部不会变。

反过来,一个可执行文件把后缀改成 .png,头部仍然是 PE 格式的 4D 5A(即 MZ),不会是 89 50 4E 47

所以 magic bytes 是目前判断文件真实类型最可靠的手段——至少在不进行完整格式解析的前提下。

4. 用 file-type 库读取 Magic Bytes

可以自己写魔数匹配逻辑,但没必要手动维护。file-type 这个 npm 库封装了上百种格式的魔数对照表,直接调用即可。

安装:

pnpm add file-type

基本用法:

import { fileTypeFromBuffer } from 'file-type'
 
const buffer = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
const result = await fileTypeFromBuffer(buffer)
// { ext: 'png', mime: 'image/png' }

fileTypeFromBuffer 接收一段字节数据,返回识别到的类型。如果无法识别,返回 undefined

对于 Hono 的文件上传场景,典型用法是读取上传文件的前面一段 buffer:

const file = body.get('file') as File
// 只读前 4100 字节,覆盖绝大多数格式的魔数位置
const slice = file.slice(0, 4100)
const buffer = new Uint8Array(await slice.arrayBuffer())
const detected = await fileTypeFromBuffer(buffer)

4100 字节这个值来自 file-type 的默认建议——绝大多数格式的魔数都在文件头部几十字节内,4100 已经足够宽裕。不需要把整个文件读进内存,这对处理大文件上传很重要。

file-type 在 Cloudflare Workers 上也可以正常使用,因为它基于纯 JavaScript 实现,不依赖 Node.js 特有的 API。

5. 白名单策略

拿到文件的真实类型之后,怎么判断「这个类型能不能接受」?

两种思路:

  • 黑名单:列出所有不允许的类型,其余放行
  • 白名单:列出所有允许的类型,其余拒绝

白名单更安全,原因有三:

  1. 新出现的文件格式或恶意伪装类型,黑名单容易漏掉
  2. 白名单的逻辑更简洁——在列表里就通过,不在就拒绝
  3. 从业务角度出发,一个头像上传接口只需要 image/pngimage/jpeg,不需要支持其他几十种格式

白名单的配置通常按业务场景划分:

// 头像上传——只要 PNG 和 JPEG
const avatarAllowed = {
  extensions: ['png', 'jpg', 'jpeg'],
  mimeTypes: ['image/png', 'image/jpeg'],
}
 
// AI 知识库场景——接受 PDF 和 DOCX
const documentAllowed = {
  extensions: ['pdf', 'docx'],
  mimeTypes: [
    'application/pdf',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  ],
}

校验时,把检测到的 extmime 都和白名单比对,两个都命中才算通过。只验一个存在绕过空间。

6. 在 Hono 中间件里实现类型校验

把上面这些步骤组合成一个可复用的 Hono 中间件。

import { fileTypeFromBuffer } from 'file-type'
import type { Context, MiddlewareHandler } from 'hono'
 
type FileValidationConfig = {
  /** 表单里文件字段的名称 */
  fieldName: string
  /** 允许的扩展名列表 */
  allowedExtensions: string[]
  /** 允许的 MIME 类型列表 */
  allowedMimeTypes: string[]
  /** 文件大小上限(字节) */
  maxFileSize: number
}
 
export function validateFileType(
  config: FileValidationConfig
): MiddlewareHandler {
  return async (c, next) => {
    let body: Record<string, FormDataEntryValue>
 
    try {
      body = await c.req.parseBody()
    } catch {
      return c.json(
        { error: 'INVALID_FORM_DATA', message: '请求体不是合法的 multipart 表单' },
        400
      )
    }
 
    const file = body[config.fieldName]
 
    // 字段不存在或不是文件
    if (!(file instanceof File)) {
      return c.json(
        { error: 'FILE_MISSING', message: `缺少文件字段:${config.fieldName}` },
        400
      )
    }
 
    // 文件大小检查
    if (file.size > config.maxFileSize) {
      return c.json(
        {
          error: 'FILE_TOO_LARGE',
          message: `文件大小超过限制(最大 ${config.maxFileSize} 字节)`,
        },
        413
      )
    }
 
    // 读取文件头部,检测真实类型
    const slice = file.slice(0, 4100)
    const buffer = new Uint8Array(await slice.arrayBuffer())
    const detected = await fileTypeFromBuffer(buffer)
 
    if (!detected) {
      return c.json(
        { error: 'UNKNOWN_FILE_TYPE', message: '无法识别文件类型' },
        400
      )
    }
 
    // 白名单校验:扩展名和 MIME 类型都要命中
    const extValid = config.allowedExtensions.includes(detected.ext)
    const mimeValid = config.allowedMimeTypes.includes(detected.mime)
 
    if (!extValid || !mimeValid) {
      return c.json(
        {
          error: 'FILE_TYPE_NOT_ALLOWED',
          message: `不支持的文件类型:${detected.mime}`,
          allowed: config.allowedMimeTypes,
        },
        415
      )
    }
 
    // 校验通过,把文件信息挂到 context,后续处理函数可以直接取
    c.set('validatedFile', {
      file,
      detectedType: detected,
    })
 
    await next()
  }
}

这里用了一个约定:校验通过后,中间件把原始 File 对象和检测到的类型一起挂到 c.set() 上。下游的路由处理函数用 c.get('validatedFile') 取出来,不需要重新解析表单。类型声明放在 AppEnvVariables 里:

type AppEnv = {
  Bindings: { /* ... */ }
  Variables: {
    validatedFile?: {
      file: File
      detectedType: { ext: string; mime: string }
    }
  }
}

在路由里使用:

const upload = new Hono<AppEnv>()
 
upload.post(
  '/avatar',
  validateFileType({
    fieldName: 'file',
    allowedExtensions: ['png', 'jpg', 'jpeg'],
    allowedMimeTypes: ['image/png', 'image/jpeg'],
    maxFileSize: 5 * 1024 * 1024, // 5MB
  }),
  async (c) => {
    const { file, detectedType } = c.get('validatedFile')!
 
    // 文件已经通过类型校验,可以安全地进行后续处理
    // 比如存到 R2、调用图片压缩服务等
 
    return c.json({
      message: '上传成功',
      type: detectedType.mime,
      size: file.size,
    })
  }
)

中间件把校验逻辑和业务逻辑分开了。路由处理函数不需要关心文件类型是怎么检测的,只需要拿结果。

7. AI 场景下的特殊考虑

普通文件上传场景,白名单通常比较窄——头像只要 image/pngimage/jpeg,附件只要 application/pdf

AI 项目的文件处理有几个不同点:

  1. 需要支持的格式种类多。文档问答要接 PDF、DOCX,甚至 PPTX、XLSX;语音识别要接 MP3、WAV、WEBM、M4A;图片理解要接 PNG、JPG、GIF、BMP
  2. 文件通常要转发给第三方 AI 服务。比如把用户的 PDF 转送给 OpenAI 的 File API,把音频转送给 Whisper
  3. 文件成为 AI 处理的输入,如果输入文件类型不对,可能导致解析失败、输出异常,甚至触发 Prompt 注入

7.1 格式白名单要跟着能力走

白名单的范围应该匹配当前功能实际需要的格式,不要为了「通用」而放大范围。

如果一个接口只处理文档问答,就不需要接受音频格式。即使系统里另一个功能需要音频,也不应该在这个接口的白名单里加 audio/*

// 文档问答接口——只接受文档类格式
const docQnAAllowed = {
  allowedExtensions: ['pdf', 'docx', 'txt', 'md'],
  allowedMimeTypes: [
    'application/pdf',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'text/plain',
    'text/markdown',
  ],
}
 
// 语音转文字接口——只接受音频格式
const speechToTextAllowed = {
  allowedExtensions: ['mp3', 'wav', 'webm', 'm4a'],
  allowedMimeTypes: [
    'audio/mpeg',
    'audio/wav',
    'audio/webm',
    'audio/mp4',
    'audio/x-m4a',
  ],
}

7.2 纯文本格式不需要 magic bytes

text/plaintext/markdown 这类纯文本格式没有固定的魔数。file-type 无法识别这类格式,会返回 undefined

对于文本格式,可以退回到扩展名检查作为补充。扩展名检查只用在白名单里已经声明为 text/* 的类型上,不能放开到所有类型。

const TEXT_EXTENSIONS = new Set(['txt', 'md', 'csv'])
 
// 在中间件的校验逻辑里加一个分支
if (!detected) {
  // 没有检测到魔数,尝试按扩展名判断是否为纯文本
  const fileName = (file as File).name ?? ''
  const ext = fileName.split('.').pop()?.toLowerCase() ?? ''
 
  if (TEXT_EXTENSIONS.has(ext) && config.allowedExtensions.includes(ext)) {
    // 白名单里有这个扩展名,且属于纯文本类型,放行
    c.set('validatedFile', {
      file,
      detectedType: { ext, mime: `text/${ext === 'md' ? 'markdown' : 'plain'}` },
    })
    return next()
  }
 
  return c.json(
    { error: 'UNKNOWN_FILE_TYPE', message: '无法识别文件类型' },
    400
  )
}

这段逻辑的位置在 fileTypeFromBuffer 返回 undefined 之后、返回错误之前。先走 magic bytes,识别不了再走文本扩展名兜底,两步都有白名单约束。

7.3 转发给第三方服务前的二次确认

如果文件要被转发给外部 AI 服务(OpenAI、Claude 等),在转发前再做一次类型确认。原因很简单:你传给第三方的文件类型,需要和第三方 API 文档里声明的格式一致。如果中间处理环节出了问题(比如格式转换产生了错误输出),在这里拦截比让第三方返回错误更好。

不同 AI 服务对上传文件有自己的大小限制。比如 OpenAI 的 File API 对某些格式限制 512MB,但实际使用中,过大的文档会导致 token 消耗激增或超时。在中间件层就把大小限制收紧到业务需要的范围内:

// 根据功能场景设置不同的上限
const limits = {
  documentQA: 20 * 1024 * 1024,     // 文档问答:20MB
  speechToText: 25 * 1024 * 1024,    // 语音转文字:25MB(Whisper 上限)
  avatar: 5 * 1024 * 1024,           // 头像:5MB
  imageGeneration: 10 * 1024 * 1024, // 图片生成:10MB
}

8. 校验失败的错误响应设计

文件类型校验失败时,错误响应需要兼顾两件事:给客户端足够的信息让开发者定位问题;不暴露内部实现细节。

不同场景用不同的 HTTP 状态码和错误结构:

场景状态码error 码说明
无法识别文件类型400UNKNOWN_FILE_TYPEmagic bytes 匹配不到已知格式。不给更多细节,避免暴露检测方式
类型不在白名单内415FILE_TYPE_NOT_ALLOWED附带 allowed 列表,告诉客户端支持的格式
文件太大413FILE_TOO_LARGE附带 maxSize,告知大小上限
表单解析失败400INVALID_FORM_DATA提示使用 multipart/form-data

「类型不在白名单内」的响应示例:

{
  "error": "FILE_TYPE_NOT_ALLOWED",
  "message": "不支持的文件类型:application/x-executable",
  "allowed": ["image/png", "image/jpeg"]
}

allowed 列表来自你自己的白名单配置,公开出去没有安全风险,对正常用户有帮助。

8.1 统一错误响应结构

把所有场景收拢到一个统一的错误响应格式里:

type FileUploadError = {
  error: string
  message: string
  allowed?: string[]
  maxSize?: number
}
 
function fileUploadError(
  c: Context,
  code: string,
  message: string,
  status: number,
  extra?: Record<string, unknown>
) {
  return c.json(
    { error: code, message, ...extra },
    status
  )
}

在中间件里统一调用 fileUploadError,不要在每个分支里手写 JSON 结构。保持错误响应的一致性,客户端的错误处理逻辑也能写得更简单。

8.2 错误码的作用

error 字段里的错误码(UNKNOWN_FILE_TYPEFILE_TYPE_NOT_ALLOWEDFILE_TOO_LARGE)是给程序用的。前端或调用方可以根据 code 做不同处理——比如 FILE_TYPE_NOT_ALLOWED 时显示支持的格式列表,FILE_TOO_LARGE 时提示用户压缩文件。

message 字段是给人看的。可以用中文,也可以根据客户端的 Accept-Language 做多语言。但 error 码保持稳定,不要随语言变化。

总结

回顾一下这篇的几个核心点:

  • 扩展名不可信——客户端可以随意修改,不能作为类型判断的唯一依据
  • Content-Type 头不可信——同样是客户端填写的请求元数据
  • magic bytes 是可靠的判断手段——读取文件内容头部的固定字节序列,对比已知格式签名
  • file-type封装了魔数匹配逻辑,只需要读取文件前 4100 字节
  • 白名单策略——只放行业务需要的类型,不试图维护一份禁止类型清单
  • 中间件封装——把解析表单、检测类型、白名单比对封装到一个可复用的 Hono 中间件里
  • AI 场景——格式更多但原则不变;纯文本格式没有魔数,需要扩展名兜底
  • 错误响应——用稳定的错误码给程序用,用可读的 message 给人看,不暴露内部检测细节