19.07-文件上传安全
要点
- 文件上传接口是安全风险最高的接口之一——攻击者可以上传恶意文件,试图执行代码、覆盖系统文件、或发起存储型 XSS
- 传统文件上传安全要点:文件类型校验、文件大小限制、文件名消毒、存储路径隔离——这些在 Workers 环境下同样适用
- Workers 环境的安全优势:没有本地文件系统(无法写 webshell)、Worker 代码只读、V8 isolate 隔离——传统文件上传攻击大多不适用
- 主要风险变成了:存储恶意文件到 R2、文件名穿越(
../../etc/passwd)、MIME 类型伪造、上传过大文件消耗存储资源 - 校验文件类型不能只靠扩展名——要检查文件头(magic bytes)
- AI 场景的新需求:用户上传文档给 RAG 系统、上传文件给 LLM 分析——需要安全处理各种文档格式
1. 传统文件上传攻击
在传统服务器上,文件上传漏洞可能导致:
1.1 Webshell 上传
攻击者上传一个 .php / .jsp / .asp 文件到 Web 可访问目录,然后通过浏览器访问这个文件执行任意命令:
// shell.php
<?php system($_GET['cmd']); ?>访问 https://example.com/uploads/shell.php?cmd=whoami → 服务器执行 whoami。
Workers 环境:Worker 没有本地文件系统,上传的文件存到 R2(对象存储)。R2 里的文件只能通过 API 访问,不会被当作代码执行。所以 webshell 上传在 Workers 环境下不成立。
1.2 路径穿越
攻击者用特殊文件名访问系统文件:
filename: ../../etc/passwd
filename: ../../../windows/system32/config/sam
如果服务端把文件名直接拼到存储路径里,可能覆盖系统文件。
Workers 环境:R2 的 key 是扁平的字符串(没有目录层级概念),../../etc/passwd 在 R2 里就是一个普通的 key 名。但如果你在存 R2 之前先用本地路径做了处理(比如临时缓存),路径穿越仍然可能有风险。
1.3 存储型 XSS
上传的 HTML / SVG 文件里嵌入 JavaScript,其他用户访问这个文件时脚本执行:
<!-- evil.svg -->
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.cookie)</script>
</svg>如果 R2 的文件通过自定义域名直接对外提供服务,其他用户访问 https://files.example.com/evil.svg 时,SVG 里的脚本会在 files.example.com 的上下文中执行。
Workers 环境:这个风险存在——R2 + 自定义域名对外提供服务时,上传的 HTML/SVG/JS 文件里的脚本会执行。
2. Hono 处理文件上传
2.1 解析 multipart/form-data
app.post('/api/upload', async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
if (!file) {
return c.json({ error: 'No file uploaded' }, 400)
}
console.log(file.name) // 原始文件名
console.log(file.size) // 文件大小(bytes)
console.log(file.type) // MIME 类型(浏览器提供,不可信)
// 处理文件...
return c.json({ ok: true, name: file.name, size: file.size })
})2.2 读取文件内容
// 读成 ArrayBuffer
const buffer = await file.arrayBuffer()
// 读成文本
const text = await file.text()
// 读成 Base64
const base64 = btoa(
String.fromCharCode(...new Uint8Array(await file.arrayBuffer()))
)3. 文件上传安全校验
3.1 文件大小限制
import { bodyLimit } from 'hono/body-limit'
// 文件上传接口:最大 10MB
app.post('/api/upload',
bodyLimit({ maxSize: 10 * 1024 * 1024 }),
async (c) => { /* ... */ }
)不同文件类型的限制:
| 文件类型 | 建议限制 | 原因 |
|---|---|---|
| 头像 | 2MB | 图片不需要太大 |
| 文档(PDF/DOCX) | 20MB | RAG 场景的文档 |
| 图片(文章配图) | 5MB | 压缩后通常几百 KB |
| 视频 | 100MB | 需要更大的限制 |
| AI 文件分析 | 取决于模型 | 通常 10MB 以内 |
3.2 文件类型校验——不能只靠扩展名
// 错误:只检查扩展名
const allowedExtensions = ['.jpg', '.png', '.pdf']
const ext = file.name.slice(file.name.lastIndexOf('.'))
if (!allowedExtensions.includes(ext)) {
return c.json({ error: 'File type not allowed' }, 400)
}
// 攻击者把 .php 文件改名为 .jpg,扩展名检查通过了必须检查文件头(magic bytes)——文件开头的几个字节决定了文件的真实类型:
// 正确:检查文件头(magic bytes)
const MAGIC_BYTES: Record<string, number[][]> = {
'image/jpeg': [[0xFF, 0xD8, 0xFF]],
'image/png': [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]],
'image/gif': [[0x47, 0x49, 0x46, 0x38]],
'application/pdf': [[0x25, 0x50, 0x44, 0x46]], // %PDF
'image/svg+xml': null, // SVG 是文本,检查 XML 头
}
async function validateFileType(file: File, expectedType: string): Promise<boolean> {
const magic = MAGIC_BYTES[expectedType]
if (!magic) return true // 没有 magic bytes 规则的类型
const buffer = new Uint8Array(await file.arrayBuffer())
const header = buffer.slice(0, 8)
return magic.some(pattern =>
pattern.every((byte, i) => header[i] === byte)
)
}
// 使用
app.post('/api/upload/avatar', async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
// 1. 检查声明的类型
if (!file.type.startsWith('image/')) {
return c.json({ error: 'Only images allowed' }, 400)
}
// 2. 检查实际的文件头
const validType = await validateFileType(file, file.type)
if (!validType) {
return c.json({ error: 'File content does not match type' }, 400)
}
// 3. 检查大小
if (file.size > 2 * 1024 * 1024) {
return c.json({ error: 'File too large (max 2MB)' }, 400)
}
// 4. 存储...
return c.json({ ok: true })
})3.3 文件名消毒
永远不要直接用用户上传的文件名作为存储路径:
// 危险:直接用原始文件名
await c.env.R2.put(file.name, file)
// file.name = "../../etc/passwd" → 路径穿越
// file.name = "<script>.svg" → XSS 风险// 正确:生成安全的文件名
function sanitizeFilename(originalName: string): string {
// 方案 1:用 UUID 完全替换
return `${crypto.randomUUID()}`
// 方案 2:保留扩展名,文件名用 hash
const ext = originalName.slice(originalName.lastIndexOf('.')).toLowerCase()
return `${crypto.randomUUID()}${ext}`
}
// 使用
const safeName = sanitizeFilename(file.name)
await c.env.R2.put(safeName, file)3.4 Content-Disposition 头
如果 R2 的文件通过自定义域名对外提供服务,设置 Content-Disposition: attachment 让浏览器下载而不是内联显示:
// 上传时设置 R2 对象的 metadata
await c.env.R2.put(safeName, file, {
httpMetadata: {
contentDisposition: 'attachment', // 强制下载,不在浏览器里打开
contentType: file.type,
},
})Content-Disposition: attachment 让浏览器弹出下载对话框而不是直接渲染文件。即使文件里有 JavaScript,也不会执行。
例外:如果你需要图片在页面里内联显示(头像、文章配图),不能用 attachment。此时需要确保文件类型校验严格,防止上传 HTML/SVG。
4. 存储到 R2
4.1 基本上传
app.post('/api/upload', async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
// 安全校验(前面讲过)
if (!await validateFile(file)) {
return c.json({ error: 'Invalid file' }, 400)
}
// 存储到 R2
const key = `${crypto.randomUUID()}${getExtension(file.name)}`
await c.env.R2.put(key, await file.arrayBuffer(), {
httpMetadata: {
contentType: file.type,
contentDisposition: 'attachment',
},
customMetadata: {
originalName: file.name,
uploadedBy: c.get('user').id,
uploadedAt: new Date().toISOString(),
},
})
// 返回文件 URL
const url = `https://files.example.com/${key}`
return c.json({ url, key })
})
function getExtension(filename: string): string {
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.webp']
return allowed.includes(ext) ? ext : ''
}4.2 文件去重
如果同一个文件可能被多次上传,用内容 hash 做去重:
app.post('/api/upload', async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
// 计算文件 hash
const buffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
// 检查是否已存在
const existing = await c.env.R2.head(`files/${hash}`)
if (existing) {
return c.json({ url: `https://files.example.com/files/${hash}`, deduplicated: true })
}
// 新文件,存储
await c.env.R2.put(`files/${hash}`, buffer, {
httpMetadata: { contentType: file.type },
})
return c.json({ url: `https://files.example.com/files/${hash}`, deduplicated: false })
})5. AI 场景的文件上传
AI 应用的文件上传场景:
5.1 RAG 文档上传
用户上传 PDF、DOCX、TXT 等文档,系统解析后用于 RAG 检索:
app.post('/api/rag/upload',
bodyLimit({ maxSize: 20 * 1024 * 1024 }), // 20MB
async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
// 1. 文件类型白名单
const allowedTypes = [
'application/pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]
if (!allowedTypes.includes(file.type)) {
return c.json({ error: 'Unsupported file type' }, 400)
}
// 2. 文件头校验(至少检查 PDF)
if (file.type === 'application/pdf') {
const buffer = new Uint8Array(await file.clone().arrayBuffer())
const isPDF = buffer[0] === 0x25 && buffer[1] === 0x50
if (!isPDF) return c.json({ error: 'Invalid PDF' }, 400)
}
// 3. 存储原始文件到 R2
const key = `docs/${crypto.randomUUID()}`
await c.env.R2.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
customMetadata: {
originalName: file.name,
userId: c.get('user').id,
},
})
// 4. 异步处理(解析 → 分块 → embedding → 存 Vectorize)
c.env.WORKFLOW.create(key) // 用 Durable Objects 或 Queue 处理
return c.json({ key, status: 'processing' })
}
)5.2 文件给 LLM 分析
部分模型支持文件输入(图片、文档)。上传后把文件内容发给模型:
app.post('/api/ai/analyze',
bodyLimit({ maxSize: 10 * 1024 * 1024 }),
async (c) => {
const body = await c.req.parseBody()
const file = body['file'] as File
// 安全校验...
// 图片 → 直接发给 Vision 模型
if (file.type.startsWith('image/')) {
const base64 = btoa(
String.fromCharCode(...new Uint8Array(await file.arrayBuffer()))
)
const result = await c.env.AI.run('@cf/llava', {
image: base64,
prompt: 'Describe this image',
})
return c.json(result)
}
// 文档 → 提取文本后发给 LLM
if (file.type === 'application/pdf' || file.type === 'text/plain') {
// PDF 解析需要专门的库(Workers 环境受限)
// 一般用外部服务或 Queue 异步处理
return c.json({ status: 'processing' })
}
return c.json({ error: 'Unsupported file type' }, 400)
}
)6. 文件上传安全检查清单
| 检查项 | 说明 |
|---|---|
bodyLimit 限制请求体大小 | 按文件类型设置合理上限 |
| 文件类型白名单 | 只允许特定 MIME 类型 |
| 文件头(magic bytes)校验 | 不能只靠扩展名和 file.type |
| 文件名消毒 | 用 UUID 替换,或用 hash + 合法扩展名 |
| Content-Disposition: attachment | 防止浏览器内联执行 HTML/SVG |
| 存储在隔离路径 | R2 的 key 前缀隔离用户上传的文件 |
| 病毒扫描 | Workers 环境受限,可用外部 API 或异步 Queue |
| 文件大小去重 | 内容 hash 去重节省存储 |
| 上传频率限制 | 配合限流中间件防止恶意上传 |
7. 小结
文件上传接口是安全风险最高的接口之一。传统 Web 的文件上传漏洞(webshell、路径穿越、存储型 XSS)在 Workers 环境下大部分不适用——Worker 没有本地文件系统,R2 的文件不会被当代码执行。
但风险仍然存在:存储恶意文件到 R2、MIME 类型伪造、文件名穿越、上传过大文件消耗存储资源、SVG/HTML 文件通过自定义域名内联执行脚本。
安全校验:bodyLimit 限制大小 → 文件类型白名单 → 文件头 magic bytes 校验 → 文件名消毒(UUID 替换)→ Content-Disposition: attachment。
AI 场景的额外需求:RAG 文档上传(PDF/DOCX/TXT,20MB 限制)、文件给 LLM 分析(图片 Vision、文档解析)。
下一篇讲 API Key 保护——密钥泄露是最常见的安全事故之一。