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)20MBRAG 场景的文档
图片(文章配图)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 保护——密钥泄露是最常见的安全事故之一。