安全的文件上传:权限、限制、签名 URL 与扫描
在 Web 应用中实现安全文件上传需要严格的权限、大小与速率限制、签名 URL 和简单的恶意文件扫描模式以避免事故。

为什么文件上传存在风险(通俗说明)
文件上传看起来无害:头像、一份 PDF、一个表格。但它们常常是第一次安全事件的起点,因为它们允许陌生人把一个“神秘包裹”交给你的系统。如果你接受它、存储它,并把它展示给其他人,就为攻击你的应用创造了新途径。
风险不仅仅是“有人上传了病毒”。一个恶意的上传可能泄露私有文件、导致存储费用暴增,或欺骗用户交出访问权限。名为 “invoice.pdf” 的文件可能根本不是 PDF。即便是真正的 PDF 或图片,也会在你的应用信任元数据、自动生成预览或用错误规则提供时引发问题。
真实的失败通常像这样:
- 有人猜到文件 URL 并下载到另一个用户的文档。
- 上传的 HTML 文件被当作网页服务,展示了窃取登录信息的提示。
- 攻击者反复上传超大文件,直到你的应用变慢或崩溃。
- 一个“安全”的文件类型被伪造,然后被内部人员在机器上打开。
一个细节导致了许多事故:存储文件不是等同于提供文件。存储是你保留字节的地方。服务是这些字节如何被传递给浏览器和应用。当应用以与主站相同的信任级别和规则来服务用户上传时,问题就会出现,浏览器会把该上传当作“受信任”的内容。
对于一个小型或发展中的应用,“足够安全”通常意味着你能在不模棱两可的情况下回答四个问题:谁可以上传、你接受什么、大小和频率是多少、以及以后谁能读取。即便你在快速构建(用生成代码或聊天驱动平台),这些护栏仍然很重要。
针对上传的简单威胁模型
把每个上传都当作不受信任的输入。保持上传安全的实际方法是想象谁可能滥用它们,以及对方的“成功”是什么样。\n\n大多数攻击者要么是扫弱点的机器人,要么是想通过上传来获取免费存储、爬取数据或捣乱的真实用户。有时也会是竞争对手在探测泄露或中断。
他们想要什么?通常是以下几种结果之一:
- 通过上传可执行内容在你的服务器上运行代码。
- 通过猜测、重用或分享下载 URL 来窃取私有文件。
- 通过洪水式上传或强制昂贵处理来破坏可用性。
- 通过存储增长或大带宽下载来抬高你的账单。
然后将薄弱点映射出来。上传端点是前门(超大文件、奇怪格式、高请求率)。存储是后室(公开的 bucket、错误权限、共享文件夹)。下载 URL 是出口(可预测、长期有效或未绑定到特定用户)。
示例:一个“简历上传”功能。机器人上传了数千个大 PDF 来制造费用,而滥用用户上传了 HTML 文件并以“文档”分享以欺骗他人。
在添加控制之前,先决定对你的应用最重要的是什么:隐私(谁能读)、可用性(能否持续提供)、成本(存储与带宽)和合规(数据存放位置及保留时长)。这个优先级列表能让决策保持一致。
切实可靠的权限与访问控制
大多数上传事故并非复杂黑客攻击,而是“我可以看到别人的文件”这类简单错误。把权限当作上传的一部分,而不是事后补上的功能。
从一句话开始:默认拒绝。假定每个上传对象都是私有的,除非你明确允许。对发票、医疗文件、账户文档和任何与用户相关的内容,采用“默认私有”是稳健的基线。只有在用户明确期望公开(比如公开头像)时才把文件设为公开,即便如此也要考虑时限访问。
与实际工作匹配的角色划分
保持角色简单且分离。一种常见划分是:
- Uploader:可以为其账户创建上传
- Viewer:可以下载被允许查看的文件
- Support:只在可审计的临时授权下访问文件
- Admin:管理策略,但不应自动读取所有内容
不要依赖诸如“/user-uploads/ 下的都安全”这类基于文件夹的规则。在每次读取时核验所有权或租户访问权限。这能在人员换岗、离职或文件被重新分配时保护你。
一个好的支持模式是窄且临时:授予对单个文件的访问、记录日志并自动过期。
在不信任的前提下校验文件类型
大多数上传攻击始于一个简单伎俩:文件看起来安全(靠文件名或浏览器头部),但实际上并非如此。把客户端发送的一切都当作不可信。
从允许列表开始:决定你接受的确切格式(例如 .jpg、.png、.pdf),拒绝其他所有格式。除非确实需要,否则避免允许“任何图片”或“任何文档”。
不要信任文件名后缀或客户端的 Content-Type 头。这两者都容易伪造。名为 invoice.pdf 的文件可能是可执行文件,Content-Type: image/png 也可能是假的。
更可靠的方法是检查文件的前若干字节,通常称为“magic bytes” 或文件签名。许多常见格式都有一致的头部(如 PNG、JPEG)。如果头部与允许的格式不符,就拒绝上传。
一个实用的验证设置:
- 服务端维持允许的扩展名列表。
- 在服务器上检测 MIME 类型(而不是信任客户端头)。
- 对支持的格式做 magic bytes 嗅探。
- 生成新的随机存储名,并把原始名称作为元数据保存。
- 阻止高风险格式,除非确实需要,尤其是 HTML、SVG 和脚本类内容。
重命名比看起来更重要。如果你直接存储用户提供的名字,会引入路径技巧、奇怪字符和意外覆盖的风险。使用生成的 ID 作为存储键,把原始文件名仅作为展示元数据。
对头像,只接受 JPEG 和 PNG,验证头部并尽可能去除元数据。对文档,考虑仅限 PDF,并拒绝任何带有活动内容的文件。如果你后来确实需要支持 SVG 或 HTML,把它们视为可能可执行并加以隔离。
大小限制、速率限制与 DoS 基础
大多数上传导致的中断并非“高级黑客手段”,而是超大文件、过多请求或慢速连接占用服务器导致应用无法响应。把每个字节都当成成本。
在实际场景中设定大小上限
为每个功能挑选最大值,而不是一个全局数值。头像不需要与税务文档或短视频相同的限制。设定看起来合适的最小限制,当确实需要时再提供独立的“大文件上传”路径。
在多个地方强制执行限制,因为客户端可能撒谎:在应用逻辑中、在 Web 服务器或反向代理上、通过上传超时机制,并在声明大小过大时及早拒绝(在读取完整请求体前)。
具体示例:头像限制为 2 MB,PDF 限制为 20 MB,任何更大的文件走不同流程(如使用带签名 URL 的直连对象存储)。
速率限制与滥用控制
即便是小文件,如果有人反复上传也会成为 DoS。对上传端点按用户和按 IP 添加速率限制。对匿名流量采用更严格的限制。\n\n可续传上传对网络不佳的真实用户有帮助,但会话令牌必须严格:短期过期、与用户绑定,并与特定文件大小和目的地绑定。否则“续传”端点会变成通往存储的免费通道。
当你阻止上传时,返回对用户友好的错误(文件过大、请求过多),但不要泄露内部信息(堆栈跟踪、bucket 名称或供应商细节)。
安全的存储与交付选择
安全的上传不仅关乎你接受什么,也关乎文件存放在哪里以及以后如何交付。
把上传的字节从主数据库中分离。大多数应用只需要在数据库中保存元数据(所有者用户 ID、原始文件名、检测到的类型、大小、校验和、存储键、创建时间)。把字节放到对象存储或专为大二进制设计的文件服务中。
在存储层面区分公开与私有文件。使用不同的 buckets/容器并设置不同规则。公开文件(如公开头像)可以无需登录直接读取。私有文件(合同、发票、医疗文档)绝不应公开可读,即便有人猜到 URL。
尽量避免从与你的主域相同的域提供用户文件。如果一个高风险文件(HTML、包含脚本的 SVG 或浏览器的 MIME 嗅探异常)滑过检查,在主域托管可能导致帐号接管。独立的下载域(或存储域)能限制影响范围。
在下载时,强制使用安全头。基于你允许的类型设置可预测的 Content-Type,不要信任用户声明的类型。对于任何可能被浏览器解释的内容,优先以下载方式发送。
一些能防止意外的默认设置:
- 对文档使用
Content-Disposition: attachment。 - 使用安全的
Content-Type(或application/octet-stream)。 - 使用不透明的对象键存储和服务(而非用户文件名)。
- 记录私有文件的下载日志。
保留策略也是安全的一部分。删除被弃置的上传、替换后移除旧版本并为临时文件设置时限。存储的数据越少,泄露的风险越低。
签名 URL:什么时候使用以及如何收紧它们
签名 URL(常称为预签名 URL)是一个常见方式,允许用户在不将存储桶设为公开、也不通过你的 API 传输每个字节的情况下上传或下载文件。URL 携带临时权限,之后失效。
两种常见流程:
- 直连存储上传:你的应用签发短期签名 URL,浏览器直接向对象存储上传。
- 通过服务器上传:文件先到你的 API,然后你的服务器把它保存到存储中。
直连存储能降低 API 负载,但这会使存储规则和 URL 约束更为重要。
如何保持签名 URL 的严格性
把签名 URL 当作一次性密钥。使其具体且短期有效。
- 写入型 URL 快速过期(通常 1–5 分钟)。阅读型 URL 也应以分钟为单位,而非几天。
- 将 URL 绑定到你期望的具体对象键(单个对象,而非文件夹)。
- 在支持时添加约束:期望的 Content-Type、最大大小、校验和。
- 仅在权限校验通过后签发 URL。
- 记录谁请求了 URL 及其原因(用户 ID、对象键、用途、IP/UA)。
一个实用模式是在签发 URL 前先创建上传记录(状态:pending),上传后确认对象存在且符合预期大小与类型,然后再标记为可用。
可实现的逐步安全上传流程
一个安全的上传流程主要是清晰的规则和明确的状态。把每个上传都当作在检查完成前不可信。
把每个功能允许的内容写下来:头像和税务文档不应共享同样的文件类型、大小限制或可见性。
实用流程(带实际状态)
-
定义允许的类型和每个功能的大小上限(例如:照片最多 5 MB,PDF 最多 20 MB)。在后端强制相同规则。
-
在字节到达前创建“上传记录”。存储:所有者(用户或组织)、用途(avatar、invoice、attachment)、原始文件名、预期最大大小,以及
pending状态等。 -
上传到私有位置。不要让客户端选择最终路径。
-
在服务器端再次验证:大小、magic bytes/类型、允许列表。如果通过,把状态改为
uploaded。 -
进行恶意软件扫描并把状态更新为
clean或quarantined。如果扫描是异步的,在等待期间保持访问锁定。 -
只有当状态为
clean时才允许下载、预览或处理。
小示例:对于头像,创建与用户绑定且用途为 avatar 的记录,私有存储,确认它是真正的 JPEG/PNG(而不是仅仅叫这个名字),扫描后生成预览 URL。
基本的恶意软件扫描模式(不过不要过度承诺)
恶意软件扫描是安全网,不是保证。它能拦截已知的恶意文件和明显的伎俩,但检测不到所有问题。目标是简单:降低风险并把未知文件默认设置为无害。
可靠的模式是先隔离。把每个新上传先保存到私有且非公开的位置并标记为 pending。只有通过检查后才把它移动到“clean”位置或标记为可用。
同步扫描只适用于小文件和低流量,因为用户需要等待。大多数应用采用异步扫描:接受上传,返回“处理中”状态,后台完成扫描。
“基本扫描”通常包括的内容
基本扫描通常包含杀毒引擎(或服务)加上一些护栏:AV 扫描、文件类型检查(magic bytes)、归档限制(zip 炸弹、嵌套 zip、巨大解压后大小),以及阻止不需要的格式。
如果扫描失败、超时或返回“未知”,把文件视为可疑。将其隔离并不要提供下载链接。团队常犯的错是把“扫描失败”当作“也发吧”。
当你阻止一个文件时,给出的信息要中性:“我们无法接受此文件。请尝试其他文件或联系客服。”除非你很有把握,否则不要声称你检测到了恶意软件。
示例:典型应用中的头像和文档上传
考虑两个功能:一个公开展示的头像和一个用于账单或支持的 PDF 收据(私有)。两者都是上传问题,但不应共享同样的规则。
对头像保持严格:只允许 JPEG/PNG,限制大小(例如 2–5 MB),并在服务器端重新编码,这样就不会直接提供用户的原始字节。检查通过后再存为公开存储。
对 PDF 收据,允许更大尺寸(例如最多 20 MB),默认私有,并避免从主域内联渲染它们。
一个简单的状态模型能在不暴露内部细节的情况下告知用户:
- pending:用户已选择文件,尚未开始上传
- uploaded:存储已接收字节
- scanning:后台作业正在检查
- clean(或 rejected):文件可用或被阻止
签名 URL 很适合这种流程:使用短期写入签名 URL 进行上传(写入权限、绑定单一对象键)。仅在状态为 clean 时签发短期读取签名 URL。
记录你需要的调查信息,而不是文件本身:用户 ID、文件 ID、类型猜测、大小、存储键、时间戳、扫描结果、请求 ID。避免记录原始内容或文件内发现的敏感数据。
常见错误与容易掉进的陷阱
大多数上传漏洞来自一个临时捷径变成永久设置。假定每个文件都不受信任、每个 URL 都会被分享、每个“我们稍后会修”的设置都会被忘记。
反复出现的问题包括:
- 仅依赖客户端检查。浏览器可以在几秒钟内被绕过。
- 让用户影响路径、文件名或对象键。
- 把文件“暂时”设为公开。
- 使用存活时间过长或可为多个用户使用的签名 URL。
- 用错误的
Content-Type提供文件,让浏览器去解释风险内容。
监控是团队最常跳过的环节,直到存储费用飙升。监控上传量、平均大小、主要上传者和错误率。一个被攻破的账号就能在一夜之间悄悄上传数千个大文件。
示例:团队在共享文件夹下使用用户提供的文件名存储头像,如 “avatar.png”。某个用户覆盖了别人的图片。解决办法很乏味但有效:在服务器端生成对象键、默认私有存储,并通过受控响应暴露经缩放的图片。
快速检查清单与下一步
在发布前把这当作最后检查清单。把每项当作发布阻断项,因为多数事故来自缺少某个护栏。
快速检查清单
- 在服务器端用允许列表和真实内容检查(而非仅文件名)以及每个文件的硬性最大限制进行验证。
- 默认私有存储,并在每次读取、下载或预览时核验权限。
- 如果使用签名 URL 上传,确保它们短期生效、绑定到单个对象键并记录签发以便追溯滥用。
- 先隔离后扫描:在文件被标记为 clean 之前不要生成预览或允许下载。
- 强制安全的下载行为:可预测的
Content-Type、安全的文件名以及对文档使用attachment。
值得投入的下一步
用通俗语言把规则写下来:允许的类型、最大大小、谁能访问、签名 URL 的生存期,以及“扫描通过”意味着什么。这将成为产品、工程和支持之间的共享契约。
添加一些能捕捉常见失败的测试:超大文件、改名的可执行文件、未授权读取、过期签名 URL 和“扫描待定”的下载。这些测试比一次事故花费要低得多。
如果你在快速构建与迭代,使用一种可以安全规划变更并回滚的工作流会很有帮助。使用 Koder.ai (koder.ai) 的团队通常在收紧上传规则时依赖 planning mode 和快照/回滚,但核心要求不变:策略应由后端强制执行,而不是由 UI 决定。
常见问题
What’s the minimum I should do to make file uploads “safe enough”?“
开始时先做到默认私有,并把每个上传都当作不受信任的输入。在服务器端执行四项基本检查:
- 谁可以上传
- 接受哪些文件类型(允许列表)
- 大小/频率限制(大小 + 速率限制)
- 谁以后可以读取(逐文件的权限检查)
如果你能清楚回答这些问题,就比大多数团队更安全了。
Why are file uploads a common first security incident?
因为用户可以上传一个“黑匣子”,你的应用可能存储并在之后把它展示给其他用户。这可能导致:
- 未经授权访问私有文档
- 如果文件被当作受信任的网页内容展示,可能引发钓鱼或帐号接管
- 上传洪水或超大文件导致服务中断或账单暴涨
通常问题不只是“有人上传了病毒”。
What’s the difference between storing files and serving files, and why does it matter?
存储是把字节放在某处;服务是把字节交付给浏览器或应用。
危险在于,当应用以与主站相同的信任级别和规则来对待用户上传时,浏览器可能把上传当作“受信任”的页面去执行或展示。
更安全的默认做法是:先私有存储,再通过受控的下载响应并使用安全头来交付。
How do I stop users from downloading someone else’s uploaded file?
采用默认拒绝,并在每次下载或预览时都做权限校验。
实用规则:
- 每个文件记录应有所有者(用户/组织)和用途(avatar、invoice 等)
- 在读/下载时,验证请求者是否有该文件的访问权限
- 避免基于文件夹的简单规则(如“/uploads/ 下的都可以”)
- 支持人员访问应为临时且有日志(只授予单个文件,自动过期)
大多数漏洞其实就是“我能看到别人的文件”这类简单错误。
How do I validate file type without trusting the filename or Content-Type?
不要信任文件名后缀或浏览器发来的 Content-Type。在服务器端验证:
- 为每个功能使用允许列表(例如头像只允许 JPEG/PNG,收据只允许 PDF)
- 在服务器端检测类型并检查magic bytes(文件签名)
- 存储时用随机 ID 重命名;原始文件名只做展示元数据
- 阻止不需要的高风险格式(尤其是 HTML、SVG、脚本类内容)
如果字节流与允许的格式不符,就拒绝上传。
What limits should I set to prevent upload-based DoS attacks?
常见的中断来源是无趣的滥用:太多上传、超大文件或慢速连接占用服务器资源。
一些适用的默认设置:
- 为不同功能设定最大值(头像小,文档大)
- 在多个层级强制执行(应用 + 反向代理 + 超时)
- 对每个用户和每个 IP 设定速率限制,匿名流量限制更严格
把每个字节都当成成本,每个请求都可能是滥用。
Should I use signed URLs for uploads, and what’s the safest default?
可以,但必须谨慎。签名 URL 让浏览器直接与对象存储交互,避免把存储设为公开。
良好默认:
- 写入型 URL 短期生效(通常 1–5 分钟)
- 每个 URL 绑定到一个对象键,而不是整个文件夹
- 只在权限校验通过后签发 URL
- 记录谁为哪个文件请求了 URL
直连存储能减少 API 负载,但范围和过期策略不可妥协。
What’s a safe step-by-step upload flow I can implement?
最安全的模式是:
- 在字节到达前创建带状态
pending的上传记录 - 将字节上传到私有位置
- 在服务器端再次验证大小 + 类型(magic bytes)
- 扫描(通常异步),把状态改为
clean或quarantined - 只有在
clean时才允许下载/预览
这能防止“扫描失败”或“仍在处理”的文件被意外分享。
Do I really need malware scanning, and what does “basic scanning” look like?
扫描有用,但不是万能。把它当作安全网,而不是唯一控制手段。
实用做法:
- 先隔离:不在扫描完成前公开链接
- 异步扫描以便扩展;向用户显示“处理中”的状态
- 如果扫描失败或超时,把文件视为可疑并保持阻断
- 如果允许归档文件,加入对 zip 炸弹和巨大解压后的大小的防护
关键在于策略:未完成扫描不应变成“可用”。
How should I deliver uploaded files safely (headers, domains, downloads)?
以防止浏览器把文件当网页解释为目标来交付文件:
良好默认:
- 对文档设置
Content-Disposition: attachment - 使用服务器选定的安全
Content-Type(或application/octet-stream) - URL 中使用不透明的存储键(而非用户文件名)
- 如果可能,为用户内容使用独立的下载域
这样可以降低上传文件变成钓鱼页面或脚本执行的风险。