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 设成任意值。
也就是说:
- 一个
.exe文件可以把Content-Type设成image/png - 一个合法的 PNG 文件也可以把
Content-Type设成application/octet-stream - 攻击者可以随意填写任何字符串
Content-Type 不是文件的属性,是请求的元数据。填什么完全取决于发送方。
它能当辅助参考——比如扩展名说 .png、Content-Type 说 application/pdf,这种不一致本身就是一个值得记录或拒绝的信号。但它不能单独作为类型判断的依据。
3. Magic Bytes:文件内容里的指纹
判断文件真实类型,需要看文件内容本身。
大多数文件格式在二进制头部有一段固定的字节序列,叫做「魔数」(magic bytes)。这是文件格式规范的一部分,和文件名、Content-Type 无关,写在文件实际数据里。
几个常见格式的魔数:
| 格式 | 魔数(十六进制) | 对应 ASCII |
|---|---|---|
| JPEG | FF D8 FF | — |
| PNG | 89 50 4E 47 0D 0A 1A 0A | .PNG.... |
| GIF | 47 49 46 38 | GIF8 |
25 50 44 46 | %PDF | |
| ZIP | 50 4B 03 04 | PK.. |
| MP3 | FF FB 或 49 44 33 | ID3 或帧同步 |
| WAV | 52 49 46 46 ... 57 41 56 45 | RIFF....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. 白名单策略
拿到文件的真实类型之后,怎么判断「这个类型能不能接受」?
两种思路:
- 黑名单:列出所有不允许的类型,其余放行
- 白名单:列出所有允许的类型,其余拒绝
白名单更安全,原因有三:
- 新出现的文件格式或恶意伪装类型,黑名单容易漏掉
- 白名单的逻辑更简洁——在列表里就通过,不在就拒绝
- 从业务角度出发,一个头像上传接口只需要
image/png和image/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',
],
}校验时,把检测到的 ext 和 mime 都和白名单比对,两个都命中才算通过。只验一个存在绕过空间。
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') 取出来,不需要重新解析表单。类型声明放在 AppEnv 的 Variables 里:
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/png、image/jpeg,附件只要 application/pdf。
AI 项目的文件处理有几个不同点:
- 需要支持的格式种类多。文档问答要接 PDF、DOCX,甚至 PPTX、XLSX;语音识别要接 MP3、WAV、WEBM、M4A;图片理解要接 PNG、JPG、GIF、BMP
- 文件通常要转发给第三方 AI 服务。比如把用户的 PDF 转送给 OpenAI 的 File API,把音频转送给 Whisper
- 文件成为 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/plain 和 text/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 码 | 说明 |
|---|---|---|---|
| 无法识别文件类型 | 400 | UNKNOWN_FILE_TYPE | magic bytes 匹配不到已知格式。不给更多细节,避免暴露检测方式 |
| 类型不在白名单内 | 415 | FILE_TYPE_NOT_ALLOWED | 附带 allowed 列表,告诉客户端支持的格式 |
| 文件太大 | 413 | FILE_TOO_LARGE | 附带 maxSize,告知大小上限 |
| 表单解析失败 | 400 | INVALID_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_TYPE、FILE_TYPE_NOT_ALLOWED、FILE_TOO_LARGE)是给程序用的。前端或调用方可以根据 code 做不同处理——比如 FILE_TYPE_NOT_ALLOWED 时显示支持的格式列表,FILE_TOO_LARGE 时提示用户压缩文件。
message 字段是给人看的。可以用中文,也可以根据客户端的 Accept-Language 做多语言。但 error 码保持稳定,不要随语言变化。
总结
回顾一下这篇的几个核心点:
- 扩展名不可信——客户端可以随意修改,不能作为类型判断的唯一依据
Content-Type头不可信——同样是客户端填写的请求元数据- magic bytes 是可靠的判断手段——读取文件内容头部的固定字节序列,对比已知格式签名
file-type库封装了魔数匹配逻辑,只需要读取文件前 4100 字节- 白名单策略——只放行业务需要的类型,不试图维护一份禁止类型清单
- 中间件封装——把解析表单、检测类型、白名单比对封装到一个可复用的 Hono 中间件里
- AI 场景——格式更多但原则不变;纯文本格式没有魔数,需要扩展名兜底
- 错误响应——用稳定的错误码给程序用,用可读的 message 给人看,不暴露内部检测细节