文件上传的安全边界:别只检查后缀

后缀只是文件名里最容易改的一段

用户选择一张图片,浏览器把文件名、类型和字节流交给服务器。很多接口只看文件名是不是以 .jpg 或 .png 结尾,通过就存进网站可以直接访问的目录。这个判断几乎说明不了字节里是什么。

文件上传的安全边界,别只检查后缀
文件上传的安全边界,别只检查后缀放大图解 ↗

文件名由客户端给出,可以随意改。名叫 report.jpg 的内容可以是一段网页,名叫 avatar.png 的内容可以是其他格式。photo.jpg.php 这种双后缀,在配置松散、仍按后缀选择处理器的旧环境里,还有可能被当成脚本。类型头 Content-Type: image/jpeg 同样由客户端填写,和内容没有强制关系。只检查后缀,等于信任了最容易被改动的那一层元数据。

上传安全是一条管道:谁可以上传,接收时如何限额,内容是否真是宣称的类型,文件名如何规范化,字节存在哪里,处理时用什么解析器,别人如何下载,下载时浏览器会怎样对待它。任何一层单独做了校验,都不能代表整条管道成立。

先限制谁可以传、能传多少

未鉴权的上传接口会被拿来存垃圾、放钓鱼页面、耗尽磁盘。即便业务需要公开投稿,也要有频率限制、单用户配额,以及验证码或等价的成本,并且默认不把文件公开到可猜测的地址。

鉴权之后还有授权。用户只能给自己的订单传凭证,不能给任意订单 id 传。服务端用会话里的用户身份查订单归属,不信任表单里的用户 id。管理端代客上传要另走权限,并留下操作者、时间和对象键。只校验「登录过」的接口,在多租户系统里等于任何登录者都能写任何租户的存储。

大小限制放在读取正文之前。反向代理和应用框架都要设上限,避免应用代码还没运行,进程就已经把巨大的请求体放进内存。限制包括单文件大小和一次请求的总大小。图片还要限制像素总量,因为有的图片头部声明极高的宽高,解码时会按像素分配内存,文件本身并不大。压缩包要限制解压后的体积、条目个数和嵌套层数,防止一个小包膨胀成巨大写入。

配额按用户、租户和全局磁盘一起算。只限制单文件,调用方仍可用许多小文件占满 inode,或把对象存储的账单抬高。接近配额时拒绝新上传,并给出可理解的错误,而不是等到磁盘写失败再抛出含路径的异常。

未完成的分片上传也要计入。对象存储上中断的分片会持续占空间和费用。用生命周期规则清理超过一定时间仍未完成的分片,并在应用侧把「已申请未完成」的记录标成过期。

用内容判断类型

接收后先把字节当作不可信输入。用解析器或文件头特征判断大类:这是不是 JPEG、PNG、WebP 或 PDF 的起始结构。文件头只能证明开头像某种格式,不能证明整个文件无害,但已经能挡掉大量只改后缀的伪装。

允许清单比拒绝清单稳妥。业务只收图片,就只接受你能重新编码的那几种图片,其余一律拒绝,包括网页、SVG、XML、脚本、可执行文件和带宏的办公文档。业务若确实要收 SVG 或 PDF,那是另一条更严格的管道,不要和头像图片混在同一个「后缀通过就算」的分支里。

SVG 和 HTML 可以携带脚本。把它们放在与主站相同的源下,用户打开时脚本会带着主站的登录态运行。产品若必须展示 SVG,先做可靠的消毒,去掉脚本、外链和事件属性,或者光栅化成位图再展示。做不到可靠消毒,就不要接收。不要用「我们只给设计师上传」代替消毒,账号被盗或设计工具导出异常时,管道仍会照单全收。

PDF 可以包含脚本、内嵌文件和外部动作。在线预览使用经过隔离的阅读器,响应头让浏览器下载而不是内联打开,并关掉不需要的活动功能。不要把用户 PDF 放进主站同源的内嵌框架里就当作预览完成。

不要相信重新拼出来的文件名后缀。通过内容识别得到类型之后,用服务端决定的扩展名保存,忽略用户给的后缀。识别失败就拒绝,不要退回到「那就相信文件名」。

文件名和路径不要交给用户

原始文件名只作为下载时的展示名,并且要清洗:去掉路径分隔符,去掉空字节,限制长度,去掉控制字符。不要把用户文件名拼进服务器路径。用户名里的 ../ 一类片段,会在拼接不慎时写到上传目录之外,覆盖应用文件或其他用户的对象。

保存路径用服务端生成的对象键,例如按租户、日期和随机 id 组成,扩展名来自识别出的类型。随机 id 的空间要足够大,避免被人猜到未公开对象的地址。可猜测的自增文件名加上公开目录,等于没有授权。

展示名放进 Content-Disposition 时使用规范的编码形式,避免引号或换行把响应头截断。用户名若包含回车和伪造的类型头,直接拼接会改变浏览器对正文的理解。服务端应拒绝或编码这些字符,而不是原样写进头里。

从右到左的控制字符,以及看起来像拉丁字母的同形字,会让审核人员误判扩展名。界面上显示类型时,以服务端识别结果为准,不以外观上的后缀为准。审核队列里同时展示识别类型、大小和对象键,不展示可执行的预览,直到管道后半段完成。

重新编码比「查一下病毒」更贴近图片

对头像和封面这类位图,更有效的处理往往是解码后按统一格式重新编码:去掉多余的元数据,丢掉无法理解的附加块,限制宽高,生成你需要的尺寸。重新编码会破坏藏在注释段或文件尾部追加数据里的花活,也能去掉 EXIF 里的定位信息。用户未必想公开拍摄地点,图片分发不应替他公开。

重新编码不是万能的。解码库本身可能有缺陷,所以要限制像素,限制处理时间,使用仍在维护的库,并把处理进程和主应用隔离开,给它单独的 CPU 和内存上限。处理失败就拒绝,不要把原始字节原样放进可执行脚本的网站目录。隔离进程没有读取密钥和访问内部网络的必要权限,它只接收字节、交回字节。

杀毒扫描可以放在管道里,当作额外信号。它依赖特征库,对未知样本会漏报,也会误报。扫描未完成前不要把对象公开。扫描结果记录引擎版本和特征库时间,便于事后解释当时为什么放行。不要把「扫描通过」写成永久信任。业务类型校验和隔离存储仍然要做。扫描超时的默认动作是拒绝或进入人工队列,不是放行。

存储位置决定脚本能不能跑起来

即使用户上传了脚本,只要它不能被网站服务器当脚本执行,危害会小一个数量级。实践上做到这些事。

对象存在对象存储或独立的上传桶里,不存在应用的源码目录,也不存在会被模板引擎扫描的目录。网站服务器对上传目录关闭脚本执行,只作静态文件,或完全不直接暴露该目录。

下载域名和主站分开。用户内容即使被浏览器当成网页,也拿不到主站的 Cookie。这是降低影响的隔离,不能代替内容校验。主站不要把用户域名放进父域 Cookie 的覆盖范围,否则隔离会被 Cookie 策略抵消。

桶策略默认私有。公开读只给确实需要长期公开的资源,例如已经审核的商品图,而且用单独的公开前缀,不把整个桶放开。应用使用的访问密钥只具备必要前缀的读写,不使用账号根密钥,不使用可写任意桶的密钥。密钥轮换时,旧密钥的失效时间要安排在新版本确认可用之后,避免上传中断,也不要让旧密钥无限期有效。

预签名不是把授权外包出去

临时凭证和预签名地址适合把大文件直接传给对象存储,应用不经手全部字节,从而减轻应用机器的带宽。预签名仍然是授权决策。签发前检查用户是否能对这个订单上传,限制内容类型和长度,限制过期时间。过期时间用分钟量级,不用长到可以转发给别人继续用。

客户端若可以随意指定对象键,就会覆盖别人的文件,或把文件写到公开前缀。对象键由服务端生成,写进签名条件里。内容类型和内容长度上限也写进条件,避免签的是图片、实际上传的是其他类型或超大对象。

上传完成后,客户端只通知「这个键已传完」。服务端向存储核对大小、类型和校验和,再把键绑定到业务记录。缺少这一步,调用方会登记一个从未上传的键,或绑定到别人的对象。核对失败就保持未绑定,不展示给其他用户。

分片上传的每一片都要落在同一次签发的上传 id 上。不要接受客户端拼出来的上传 id。完成后的合并请求再次核对总大小和校验和。

下载时再做一次边界

上传时严格、下载时宽松,仍然会出问题。下载接口要重新做授权:这个用户能不能看这张凭证。不可猜测的随机 id 不能代替授权,因为 id 会进入浏览器历史、引用页和截图。

响应头要明确。Content-Type 用服务端识别的类型,不用用户当初的声明。加上 X-Content-Type-Options: nosniff,减少浏览器凭内容去猜类型并执行。对不应内联的类型使用附件下载的处置头。不返回用户可控的 HTML。若必须返回文本,使用纯文本类型并配合 nosniff。

缓存要分清公私。私有文件不要被共享缓存存下,响应里标明仅私有缓存,并避免把带授权的地址放进会长期存在的页面源码。公开图片可以用长缓存,但地址应包含内容哈希或版本,避免换图后仍看到旧对象。

引用页若会带上预签名地址里的查询串,日志和第三方统计就可能收下临时凭证。预签名地址不要出现在会外链的页面里。需要外链时,走一次服务端授权的跳转,或使用更短寿命、权限更窄的地址。

别的入口也算上传

上传不一定来自浏览器表单。有的功能让服务器去拉取用户给出的地址。这会带来服务端请求伪造:服务器去访问内网地址、云元数据地址或本地文件。业务若需要远程拉取,只允许公网地址,解析后拒绝回环、链路本地和私有网段,禁止重定向到这些地址,并限制协议为 HTTPS,限制响应大小和跳转次数。拉取下来的字节走和直传相同的内容管道,不因为「是我们的服务器下载的」就信任。

解析地址时注意域名重新绑定:检查时得到的是公网地址,连接时又变成内网地址。更稳妥的做法是连接你刚刚解析并允许的那个地址,或在连接层再次校验。这是实现细节,但漏掉就会使前面的网段判断失效。

管理后台的压缩包导入会解压到磁盘。解压时拒绝绝对路径,拒绝包含上级目录片段的条目,限制目标目录,并在写入前累计解压后的总大小。这些检查放在解压库的封装层,不放在每个调用点临时拼。解压进程的权限和图片处理进程一样要收紧。

图片服务若支持按地址裁剪,参数里的地址同样要过拉取限制,尺寸参数设上限,避免一张图被请求成超大像素的派生任务,打满队列。宽度只允许落在预定阶梯上,质量只允许少数档位。

日志、审核和删除

原始文件名、EXIF、IP 和用户 id 会在日志里相遇。排障可以记对象键、识别出的类型、大小、用户内部 id 和拒绝原因。不要记预签名地址的完整查询串,因为里面有临时凭证。不要把图片字节打进日志。拒绝原因给用户的是可理解的短句,给内部的是错误码,避免把解析器的内部路径暴露出去。

审核状态做成显式字段:已接收、处理中、已拒绝、已公开。未通过审核的对象不能走公开地址。处理中的对象只对上传者可见,并且可见的是处理完成后的派生结果,不是原始字节,除非业务明确要保留原件且访问受控。

删除要同步到存储。数据库把记录标成删除、对象仍公开,等于没删。删除失败要进入重试队列,并告警,而不是只改数据库。对需要保留期限的凭证,到期删除同样走这条队列,并在审计流里留下「已删除」的事实,不留下文件内容。

发布前对着管道问一遍

新做一个上传点时,用这些问题避免退回到只看后缀:

  • 是否鉴权,并且授权到具体资源,而不是只校验登录。
  • 代理、应用和处理进程是否都有大小、像素和时间上限。
  • 是否按内容允许清单识别类型,并由服务端决定扩展名。
  • 用户文件名是否完全不参与服务器路径。
  • 图片是否重新编码,定位信息是否默认丢弃。
  • 存储是否私有,是否与主站不同源,脚本执行是否关闭。
  • 预签名的键、类型和长度是否由服务端固定,完成后是否再核对。
  • 下载是否再次授权,响应头是否禁止类型嗅探。
  • 远程拉取和压缩包是否有单独的限制,失败时是否默认拒绝。
  • 日志是否不含临时凭证和文件正文。

测试不要只传一张正常的 PNG。至少覆盖:错误的类型头但正确的字节,正确的类型头但无关字节,超大文件,超大声明像素,文件名含路径分隔符,未登录,登录后访问他人资源,预签名过期,对象未上传就绑定,审核未完成却请求公开地址。这些用例能防止回归。

文件上传的边界是整条路径的约束,不是单个条件判断。后缀可以用于用户界面上的提示,例如文件选择框只展示图片,但服务器必须假设后缀和类型头都不可信,只根据授权、限额、真实内容和隔离后的存储做决定。

见字如晤

图解