上传的七道闸里,最不该信的是浏览器给的那个类型

一、先说清一件事:这不是一道闸,是一条责任链
上传整改最容易做错的地方,是把“防”理解成找一个万能校验函数。真实结构是一条链:先判断这是什么(判),再决定放在哪、叫什么、谁能读(存),最后决定怎么给出去(发)。三段每段都要有自己的闸和自己的记录,任何单点判断都能被绕,但三段同时被绕的概率会掉下去。这条划分还有一个附带好处——出了争议时,你能明确回答“这一份是谁、什么时候、从哪个来源传上来的,当时命中了哪条规则”,而不是回答“我们有做校验”。
需要先声明立场:下面写的是交付侧的责任划分与验收方法,不是任何具体站点存在问题的断言,也不涉及任何绕过手法。我们要的是把闸装在正确的位置,并让它可核对。
二、七道闸与各自的失效方式
那篇整改文章按七道关口拆开,我们把它抄成了验收清单的表头。
| 关口 | 它在做什么 | 为什么不能单独依赖 |
|---|---|---|
| 浏览器提交的类型 | 读取表单里带过来的文件类型声明,即 $_FILES['type'] 那一项 |
这个值由客户端提交,可以直接伪造,它是提示不是证据 |
| 后缀名名单 | 按名单允许或拒绝扩展名 | 黑名单永远缺一条;名单还可能被两层后缀绕过,来源给的例子就是图片类后缀之后接一个脚本类后缀,形如 .jpg.php |
| 读文件头判类型 | 用文件信息扩展取魔数判真实类型 | 魔数只在前几个字节,首部可以伪造;判出来的类型也不等于内容安全 |
| 图像库重压缩 | 解码再编码,顺带清掉嵌入内容 | 并非对所有格式都会重编码,对压缩包和文档类格式根本不起作用 |
| 存储目录隔离 | 把上传目录挪出 Web 根,或在网关层直接拒绝对脚本后缀的访问 | 一次配置失误就把隔离抵消,隔离是环境属性而不是代码属性 |
| 目录与文件权限 | 取 755 或 644 这类基线,且属主不能是运行账号 | 运维脚本、临时调试、一次性修复都会把权限改回去 |
| 下载强制附件 | 通过强制附件头把内容当文件下发而不是当页面渲染 | 跨域预览、浏览器策略变化都会让这个头的效果打折,它不该是唯一的处置手段 |
看这张表的正确方式不是逐条加固,而是看结构:前三道属于“判”,中间两道属于“存”,最后一道属于“发”。七道里有六道都能被单独绕过,但把它们串起来的意义在于——绕过判的那一段,还有存和发兜着;这就是纵深。
三、判真实类型的三档,按成本选
第一档只判魔数。用文件信息扩展读文件头,得到一个类型结论,再显式拒绝脚本类与命令行类结果。这一档的成本最低,能挡住的是“把脚本改名成图片传进来”这类最常见的情况。它的弱点是首部可伪造,且判出来的类型粒度粗。
第二档在魔数之上加结构校验。图片类要继续走图像尺寸校验,能读不出尺寸的、尺寸与声明明显不符的直接拒。这一档能挡住的是“首部对但内容不成形”的那一类,也顺带把损坏文件与占位文件挡在入库之前。它同时也是成本控制手段:一个能被解析器读出完整结构的图片,才会进入后续处理队列。
第三档再加内容重编码,用图像库把图片解出来再压回去,只保留重新生成的那份。这等于承认“进来的字节不可信,只信我们重新写出来的字节”,顺带清掉嵌入内容。代价是画质与格式支持范围的取舍,以及处理耗时。适用边界要讲清楚:它对图片类有效,对压缩包、文档类无效,后者必须有单独策略(不进可解析范围、只做原样存储与受限下发,或者走专门的内容检查环节)。
我们的选择规则很直白:面向公众的入口取第三档;受控的机构内部入口取第二档;任何情况下都不允许只停在第一档就把判据交给浏览器的类型字段。
四、存的两件事:改名与位置
重命名的正确写法有三条,缺一条就前功尽弃。第一,不把原始扩展名保留成可执行依据——存储名用随机标识生成,扩展名要么不写,要么由我们自己按判定结果写,绝不由客户端名字推导。第二,原名与业务名分字段留,原名只作为展示与追溯的数据列存在,永远不参与路径拼接。第三,原名不进路径:拼接出来的目录里出现调用者可控的片段,等于把存储结构交给外部决定。
存储位置的隔离分四层,从上往下是递进关系,能取到第几层就取到第几层。第一层是把上传目录放到 Web 根之外,让静态服务直接读不到它。第二层是即使被读到,那个路径也不允许被解析成脚本——或者反过来在网关层直接拒绝对脚本类后缀的访问,把解析能力从存储位置剥离。第三层是给上传内容一个独立域名或子域,让浏览器不把主站的凭据与同源能力带过去。第四层是私有读写加短时签名链接,文件默认不可公开访问,取用要有时效凭证。第四层实现成本最高,但对包含个人信息或业务资料的内容,它是唯一能同时满足“可追溯”和“可撤回”的做法。
五、发的处置与权限基线要写进部署清单
下载有两种模式:直接输出和强制附件。判断哪种内容必须走强制附件,标准不是“它是不是敏感”,而是“浏览器会不会把它当可执行或可渲染的东西处理”。凡是可能被浏览器当页面、脚本、样式或带脚本的容器格式解释的内容,一律强制附件下载,不给内联渲染的机会;自己生成的、内容完全受控的静态资源才可以直接输出。同时要把“强制附件”当成兜底而非主控制:它依赖浏览器行为,一旦跨域预览或嵌入到别的上下文,效果不由我们单方决定,所以判与存两段必须自己站得住。
权限基线的价值在于固化,而不是当时设对。我们把它写成部署清单里的四项:属主与所属组、目录位、文件位、上传模块的默认值。属主不能是运行账号这条要单独强调——运行账号能写、又能被解析,就等于给进程自己留了一条改内容再执行的路。基线取 755 或 644 这一档并按角色区分(内容目录不可执行、脚本目录不可写)。真正让这四条有效的是配置漂移检查:把清单变成可自动比对的项,纳入发布后的一次核查和周期巡检,一旦偏离就告警。因为按经验,权限被改回去通常不是攻击,而是一次“临时给个权限让功能跑起来”的运维操作。
六、限额、留证字段与三段验收法
批量与多文件上传要把限额当成功能来设计:单文件大小上限、单次总大小上限、同一主体单位时间内的次数上限,以及超出之后的语义——是排队、拒收还是部分接收。这三者要写进接口约定,不能让调用方猜。限额的作用除了挡资源占用,也是给重编码这类耗时环节设的缓冲区。
留证字段我们按最小集来定,缺一项事后就要靠猜:文件标识、判定出的真实类型、大小、内容哈希、上传者标识、时间与来源 IP、命中的拒绝规则(拒绝也要有记录)。最后这一项最常被忽略,但它是判断“闸有没有在工作”的唯一证据——没有拒绝记录,只有两种解释:没人试过,或者闸根本没生效。
验收按三段各准备一组正例与反例。判的那段:正例是各类型的正常文件都能被正确识别并入库,反例只验“是否被拒”以及是否落了拒绝记录,不去验证任何具体绕过方式。存的那段:核对存储名是否完全由系统生成、原名是否只存在于数据列、上传目录在 Web 根内是否访问不到、权限四项是否与基线一致。发的那段:核对不同类别内容的下载模式是否与约定相符、签名链接是否按时效失效、私有内容在公开路径下是否取不到。三段各自记结论与证据位置,形成一张可复查的表。洹拓给这类整改的交付物就是这张表本身:对甲方来说,七道闸不是一句“我们做了防护”,而是清单里那几项权限默认值、拒绝也要留下的记录,外加一份可核对的配置漂移检查结果。