上一节讲 CSRF 时,我们一直在追问:浏览器发出的请求,真的是用户此刻想做的事吗?文件功能正好接着这个问题往下走。上传头像会改写资料,替换合同会改变业务凭据,删除附件会让数据消失。它们都属于状态变更,所以登录校验、对象级授权和 CSRF 防护一个都不能少。
可文件一旦进入系统,麻烦才真正开始。服务器还得判断它是不是业务允许的内容,临时副本放在哪里,谁能处理,处理失败会留下什么,下载时该不该交给当前用户,删除时能不能把缩略图和缓存一起收走。
很多系统把安全逻辑压缩成一句话:“我们限制了后缀。”这听起来像是已经检查过了,其实只回答了文件名最后几个字符是什么。文件处理安全关心的是一条完整链路:接收、识别、隔离、处理、发布、读取、替换、归档和删除。链路上任何一个状态跳错,都可能让本来无害的业务功能越过边界。
本章的验证只用于本地靶场、教学环境或有明确书面授权的系统。测试前要确认账号、允许类型、大小上限、请求频率、测试时段和清理责任人。不要上传可执行内容,不要制作高压缩比文件,不要读取系统文件或真实用户文件。一个无害样本已经足以证明控制缺失时,就不要继续扩大影响。

如果我们只盯着上传表单,很容易把“收到文件”和“允许使用文件”混为一谈。更稳妥的做法,是先把一份文件在系统里的状态写清楚。
这里最值得警惕的是“半完成状态”。比如,数据库已经写入附件记录,对象存储也返回了地址,但扫描任务还在队列里。如果应用此时就把下载链接展示出来,那么“先发布、后检查”会制造一个短暂却真实的暴露窗口。
异步处理还会带来版本问题。假设用户上传了版本 A,扫描任务开始后又把它替换成版本 B。任务完成时,如果系统只记录“这个附件扫描通过”,而没有确认结果对应的内容哈希和版本号,就可能拿 A 的结果批准 B。状态机里的每次转换都要带上对象标识、文件版本和规则版本。
以后看到任何文件功能,都可以固定追问四件事:
这四个问题比背一串危险后缀更实用。因为同一份文件可能在上传入口是“数据”,进入预览服务后变成“解析器输入”,到了浏览器里又变成“可渲染内容”。风险会随处理方式变化,不会由扩展名一次决定。
上传接口首先是一个业务接口。服务器要确认当前会话有效,当前用户能给这个订单、工单或资料对象添加附件,并且这次状态变更经过了应用规定的用户意图校验。前端按钮隐藏、页面路由守卫和客户端文件选择限制都只能改善体验,真正的判断必须在服务器上再做一次。
然后才轮到资源边界。大小限制不能只写在页面提示里,也不能只放在最靠后的应用代码中。反向代理、应用接收层、临时存储、对象存储和后续解析服务要有一致或更严格的上限。否则请求虽然最终被应用拒绝,前面的网络带宽、内存或临时磁盘可能已经被占满。
配额也不能只看“单文件大小”。一个账号连续上传许多刚好低于上限的小文件,照样能耗尽容量。实际要同时考虑:
“请求体只有 5 MB”不代表处理成本只有 5 MB。压缩数据会展开,图片会解码成像素缓冲区,文档会生成多页预览。入口字节上限和处理后的资源上限必须分开设置。
浏览器提交的文件名来自客户端。把它直接当作磁盘路径、对象存储键或日志字段,会把路径解释、覆盖、特殊名称、字符编码和日志注入问题一起带进来。
更清楚的模型是把“显示名”和“存储名”彻底分开:
随机命名主要解决覆盖、路径和可猜测性问题。它不能代替对象级授权。即使标识足够长、几乎猜不到,只要一个用户拿到另一个对象的标识后就能下载,授权仍然是坏的。

一份上传内容通常同时带着几种“类型说法”:文件名有扩展名,请求分段里有客户端声明的媒体类型,文件开头可能有格式标记,完整字节结构又可能符合或不符合某种规范。这些信息用途不同,不能合并成一个绝对可靠的结论。
可以把检查分成五层:
文件开头的格式标记也不是通行证。它只能说明前几个字节像某种格式,不能保证整个对象结构有效,更不能保证后续解析器不会遇到危险内容。反过来,解析成功也不代表适合公开展示:一个格式完全合法的文档,仍可能含有业务不允许的外部引用或活动内容。
多层校验不是要求每一层都“识别攻击”。它们各自回答一个小问题:业务需不需要、声明是否一致、结构能否解释、内容是否适合处理、发布条件是否齐全。把这些结果一起用于状态转换,比寻找一个万能检测器可靠得多。
上传内容不该直接落进应用代码目录,也不该写进由 Web 服务器直接公开的目录。更理想的做法是使用独立存储服务;做不到时,至少放在 Web 根目录之外,并让存储账号、扫描账号、转换账号和发布账号只拥有完成各自任务所需的权限。
权限边界可以这样拆:接收服务只能创建隔离对象,扫描服务只能读取隔离对象并写入结果,转换服务只能写派生区,发布服务根据完整结果改变业务状态,下载服务只读已发布对象。任何一个服务都不需要同时拥有“改应用代码、读全部用户文件、向公网任意出站”的能力。
存储层还要明确禁止把上传内容当作服务器端代码、模板或配置加载。即使某个业务确实允许用户上传 HTML、SVG 或其他可渲染格式,也不代表它们适合从主站同源直接打开。对活动内容要么净化并生成安全派生物,要么放在隔离域中以严格响应头交付。

异步系统最容易出现的错误,是不同组件各自更新一个布尔值:scan_ok=true、preview_ready=true、published=true。这样很难知道三个结果是不是属于同一版本,也很难处理任务重试和乱序完成。
更稳妥的发布条件可以写成一条明确规则:
只有当:
对象仍处于“检验中”
并且对象版本等于检查结果版本
并且当前规则要求的检查全部成功
并且业务对象仍允许附件发布
才把当前版本原子地切换为“已发布”
其余情况:保持不可访问,记录原因,等待重试或进入清理这里的“业务对象仍允许”经常被遗漏。合同上传后订单可能已经取消,帖子附件审核完成时帖子可能已被删除,头像转换结束时账号可能已被冻结。发布不是纯技术动作,它也要重新确认当前业务状态。
一份文件到了服务器之后,通常会被打开很多次:图片库读取尺寸并生成缩略图,文档服务抽取文字和页面,媒体服务解码再转码,压缩服务展开目录。每次打开都把不可信字节交给了一个复杂解析器。
所以,“文件不会在 Web 目录执行”还不够。解析器自身可能出错,输入也可能用很小的传输体积换来很大的 CPU、内存、磁盘或任务队列消耗。处理服务应运行在低权限隔离环境中,限制 CPU 时间、内存、临时磁盘、输出大小、子进程和网络访问,并保持库与运行环境及时更新。
允许压缩包的业务需要单独的规则,不能把普通上传检查原样套过来。开始解压前,系统至少要读取目录信息并评估:条目数量、目录深度、声明的展开总量、单条目大小、压缩比、重复名称和允许的内部文件类型。
真正解压时还要持续计数,因为归档元数据也属于不可信输入。只要实际展开字节数、条目数、耗时或目录深度超过预算,就终止任务并清理已经写出的临时内容。
每个条目的最终路径都必须在受控解压目录中。不要只从名称字符串中删掉几个可疑片段,而要用当前平台的路径 API 组合、规范化,再比较最终位置与基准目录的关系。符号链接、硬链接、绝对路径、设备文件和特殊条目通常都应拒绝。不同条目规范化后如果落到同一目标,也要作为冲突处理,不能静默覆盖。
不要在生产环境用“解压炸弹”验证资源限制。它会真实消耗磁盘、内存和处理队列。授权验证应使用体积很小、内容清楚、能立即清理的普通归档,只检查规则和状态是否按设计执行。资源耗尽测试要另行授权,并放到有硬配额的隔离环境。
转换后的缩略图、抽取文本和预览 PDF 不是自动可信。它们需要新的内部标识、来源版本和生成器版本。生成任务失败时,系统不能退回去直接公开原件;生成器升级后,如果安全规则要求重新处理,旧派生物也要能被定位和替换。
处理服务最好只接收对象标识,不接收用户提供的任意路径。它从受控映射取输入,再把输出写到独立派生区。这样既减少路径问题,也便于在删除原件时找到全部派生对象。
很多上传系统入口做得很严,下载却只剩一句“知道 URL 就能访问”。这相当于前门验了证,后门把钥匙贴在墙上。
客户端下载时最好提交业务对象标识,而不是磁盘路径或对象存储键。服务器先在当前用户、当前租户和当前业务关系的范围内查询记录,再检查文件状态,最后从内部映射取得存储对象。判断应至少回答:

这些判断要发生在每一次请求上。分段下载的后续请求、重新打开旧链接、CDN 回源和预览接口都不能复用一个已经失效的授权结论。
下载响应也属于文件安全边界。服务器要按实际交付内容设置明确的 Content-Type。对不需要在页面里执行的文件,使用附件下载方式,并在 Content-Disposition 中提供经过安全编码的显示名。显示名中的路径分隔符、控制字符和超长内容不能原样进入响应头。
浏览器主要根据响应媒体类型决定怎样处理内容,而不是只看扩展名。对用户上传内容发送 X-Content-Type-Options: nosniff,可以减少浏览器自行猜测类型的空间。必须在线预览的高风险格式应放在隔离来源,并使用限制脚本、导航、弹窗和外部连接的沙箱策略。不要为了“预览方便”把未知内容都标成可内联执行的类型。
私有文件还要设置合适的缓存策略。共享缓存不能把甲用户的响应交给乙用户。对象存储或 CDN 的临时签名地址要短时有效、绑定明确用途,并能在文件删除或分享撤销后失效。签名地址只是限时取件凭证,不是永久权限模型。
缩略图生成、批量导出、日志查看、主题加载、归档解压、附件替换和删除任务,都可能把输入拼成路径。路径遍历的根因不是出现了某个特定字符串,而是外部数据参与构造路径后,文件系统解析出的最终位置跑出了业务允许的目录。
最好的修复是让外部输入根本不决定路径。客户端提交对象标识,服务器从数据库映射到内部存储键。业务键数量有限时,用固定映射选择资源。只有在业务确实需要接收名称时,才进入更复杂的路径处理。
路径检查应该按固定顺序进行:
先完成约定的一次解码,并在明确的字符、长度和语义允许清单内验证。不要在不同层反复解码同一输入。
使用平台提供的路径 API,把受控基准目录与候选名称组合并规范化。不要用字符串替换模拟文件系统。
计算候选位置相对基准目录的关系,确认结果不是绝对路径,也没有离开基准目录。单纯比较字符串前缀会把名称相似的相邻目录误判为内部目录。
打开文件时限制链接跟随,并考虑检查完成后、真正打开前对象被替换的时间差。高风险场景应使用平台提供的安全打开能力或不可变对象键。
数据库里的路径也不能自动当作可信值。旧版本可能保存过用户输入,迁移脚本可能写入异常数据,后台导入也可能跳过入口校验。真正接触文件系统之前,所有来源都应经过同一套最终边界判断。
替换文件不是“在原位置覆盖一下”。覆盖会让旧链接、旧扫描结果、缓存和审计记录全都变得含糊。更清楚的做法是创建新版本:新内容进入隔离区并完成检查,业务记录再原子地指向新版本;旧版本按照留存策略转入归档或删除队列。
替换动作要重新检查操作者是否有权修改当前对象,也要处理并发。两个人同时替换同一附件时,系统应通过版本条件发现冲突,不能让最后一次写入悄悄覆盖前一次结果。
删除则是一条工作流,而不是一次存储 API 调用。常见顺序是:
“数据库记录删了”不等于“文件删了”。反过来,直接删掉存储对象却保留业务引用,也会制造损坏状态。删除任务应该幂等:重复执行不会误删其他对象,已经清理的目标会返回可确认的结果,失败项可以单独重试。

专业测试不靠危险内容证明问题,而是让每个安全假设都对应一份可识别、可回收的证据。开始前建立样本清单,每个样本记录测试编号、用途、上传账号、业务对象、预期结果、对象标识、内容哈希和清理状态。
可以准备下面这组最小样本,全部由测试团队自己创建,不含脚本、宏、外链、个人信息或隐藏内容:
样本编号最好同时写在文件内容和测试记录里。这样即使平台重命名了显示名,我们仍能确认下载回来的究竟是哪一份。测试完成后也能用编号在日志、对象存储和任务队列中找到完整轨迹。
先用允许类型建立正常基线,记录上传响应、对象状态变化、预览生成时间、下载方式和关联请求号。没有基线,异步延迟、缓存旧版本和真实拒绝很容易混在一起。
再提交无害的声明不一致样本,只验证名称、客户端媒体类型、服务端识别和结构检查是否协同工作。样本被错误接受后证据已经成立,不要升级成可执行内容。
用两份同名无害文件检查随机命名与版本管理。分别下载并核对内容编号,确认新文件没有覆盖旧对象,也没有复用旧扫描结果。
使用两个专用测试账号各自上传一份带编号的文件。账号甲正常下载自己的对象,再请求账号乙的测试对象,只观察服务器是否拒绝,不枚举更多标识。
路径边界验证也要控制影响。可以请系统所有者在隔离靶场里建立两份普通标记文件:一份位于允许目录,另一份位于相邻但禁止的测试目录。两份都只含测试编号。测试只确认应用能访问内部标记、不能访问外部标记,不去读取操作系统配置或真实业务数据。
出现以下任一情况就停止继续操作,并按约定通道通知负责人:看见非测试用户的对象或元数据;解析服务、队列或磁盘出现资源异常;样本进入公开区域且无法立即撤销;系统返回了敏感内部路径;清理任务无法确认结果;测试动作触发了未经批准的外部网络连接。
停止并不妨碍形成结论。对象状态、关联号、规则结果和最小响应元数据通常已经足够。不要为了让报告“更有冲击力”继续读取真实内容。

只记录一句 upload failed,既不能帮开发定位哪层出错,也不能让防守方判断是不是重复滥用。文件链路应使用统一关联号,把接收、识别、扫描、转换、发布、下载、替换和删除串在一起。
日志字段本身也可能包含用户输入。显示名进入日志前要限制长度、处理换行和分隔符,避免破坏日志结构。不要记录文件正文、会话令牌、临时签名地址和不必要的完整路径。文件哈希可以用于版本关联,但不能未经批准就把哈希或样本发送到第三方扫描平台。
告警要结合业务基线。一次格式不一致可能只是误操作;同一账号短时间内连续触发格式不一致、跨对象下载拒绝和路径边界拒绝,就更值得调查。测试时提前把编号和时段告知防守方,还可以顺手验证告警是否真的抵达负责人。
“存在任意文件上传”这种结论太粗。开发团队不知道是入口没限制、隔离区能直达、扫描结果没绑定版本,还是下载缺少授权。
更可执行的写法是:
专用测试账号提交了一份只含中文测试编号的普通文本。该样本的声明类型与业务允许类型不一致,但对象在结构检查完成前进入“已发布”状态,并可由另一专用测试账号读取。样本不含脚本、宏或外部连接。关联号和状态时间线表明,发布服务没有等待当前版本的检查结果,下载服务也没有验证对象所属账号。
接下来把每条证据映射到负责组件:接收层负责入口与配额,识别层负责格式结论,发布层负责状态转换,存储层负责隔离,下载层负责授权与响应头,删除任务负责清理闭环。这样报告讨论的是能修复的系统行为,不是测试者“传进去了什么”。
走完整条链路会发现,技术控件只能回答一部分问题。一个结构完全正常的合同,普通员工是否能下载,取决于合同归属和审批状态;头像能否替换,取决于账号关系和用户意图;归档文件是否允许删除,还取决于审计与留存要求。
这正好引出下一节的“逻辑漏洞与业务安全”。文件功能已经给了我们一个很好的练习:先画状态,再列角色与动作,然后用最小、可回滚的操作验证每一次状态转换。下一节我们会把这套方法用到优惠、库存、审批、支付和并发流程中,看看系统在“输入都合法”的情况下,业务规则为什么仍可能被绕过。
让进程只拥有业务目录所需的最低权限。即使应用层判断出错,操作系统和存储策略仍能限制影响范围。
对同一份自建文件依次测试分享、撤销、归档和待删除状态,确认每次下载都会重新判断当前权限与状态,旧链接和缓存不会继续放行。
删除全部测试对象,核对原件、派生物、索引、任务、分享地址与缓存的清理结果,并让系统负责人确认没有仍在排队的处理任务。