文件上传与编码:字节怎样安全变成参数和文件
同一参数在本地是“深度”,到生产变成乱码;上传 100 MiB 文件只配了请求上限,堆仍然被打满;客户端中断后业务没有记录,磁盘临时目录却持续增长。字符参数与文件上传都从字节开始,错误往往发生在“字节由谁解释、暂存在哪里、失败后谁删除”这三个所有权转换点。
字符编码必须在第一次参数解析前决定
HTTP 内容是字节。媒体类型的 charset、协议默认规则和应用约定共同决定如何解码。Servlet 容器可能在第一次调用 getParameter() 时解析 query/form 数据;若 Filter 在此之后才 setCharacterEncoding(),参数已经按旧编码生成 String,后改设置无法修复。
URI 查询部分如何解码还受 Connector 配置影响,不能假设与请求体始终相同。代理若先解码再编码,也可能改变 %2F、+ 和保留字符语义。排查要保留原始 Content-Type、原始字节的安全摘要、Connector URI 编码配置和第一次参数访问位置。
examples/backend-development/servlet/upload-encoding/EncodingFailureDemo.java 展示同一 UTF-8 字节被 ISO-8859-1 错解后的差异:
javac --release 17 -Xlint:all -Werror examples/backend-development/servlet/upload-encoding/MultipartBoundaryDemo.java examples/backend-development/servlet/upload-encoding/EncodingFailureDemo.java
java -cp examples/backend-development/servlet/upload-encoding EncodingFailureDemooriginal=深度 wrongEqualsOriginal=false repaired=深度 byteLength=9示例能用保留原始字节“修复”,生产链路若已经把错误 String 存库或经过替换字符,原字节可能丢失,不能依赖二次转码补救。正确做法是在入口统一 UTF-8 契约、在解析前设置、用多字节字符集成测试,并监控解码错误而非静默替换。
multipart 是字节分帧协议,不是字符串 split
multipart/form-data 的 Content-Type 携带 boundary。请求体由起始边界、每个 part 的 Header、空行、part 内容、下一边界和最终 -- 终止标记组成。边界可能跨网络缓冲区出现,文件内容也可能包含相似字节;真实解析器必须用流式匹配状态机,不能把完整 body 转 String 后 split。
MultipartBoundaryDemo.java 提供可读的最小帧样本:
java -cp examples/backend-development/servlet/upload-encoding MultipartBoundaryDemoboundaryMatched=true parts=1 bytes=90 terminalBoundary=true这段程序只证明边界结构,不是安全解析器。生产解析要处理 boundary 引号、CRLF、Header 编码、同名字段、多文件、跨缓冲匹配、缺失终止边界和提前 EOF,并为 Header、字段、单 part、part 数量与总请求分别设上限。
Part 背后可能是内存,也可能是临时文件
容器通常依据阈值把小 part 保存在内存,大 part 写入临时目录。阈值不是“允许的最大文件”,而是存储切换点;最大文件和最大请求还需独立限制。并发 200 个请求各缓存 1 MiB,已经是 200 MiB 堆外或堆内压力;临时目录容量也必须按并发、最大体积和清理延迟预算。
调用 Part.write() 的语义与容器实现、目标路径和文件系统有关,可能移动临时文件,也可能复制。不能假设原子跨盘移动。更安全的持久化流程是:在受控临时目录流式写入;计算大小与摘要;完成 MIME/恶意内容检查;fsync 或确认对象写入;以服务端生成的 id 原子发布;最后删除临时文件。业务数据库只保存已发布对象标识。
客户端文件名是不可信元数据。浏览器可能提交路径片段、Unicode 混淆、控制字符或设备名。服务端不能直接拼接到磁盘路径;应剥离路径语义、限制长度与字符、记录展示名,物理对象名使用服务端生成值。Content-Type 同样只是一项声明,需要魔数、解析器和业务规则联合验证。
流式处理必须同时有配额和背压
不把文件读进堆,并不等于没有容量风险。流式上传持续占连接、容器请求、临时空间、杀毒扫描器和下游带宽。慢上传攻击用极低速率占住资源,因此要设置 Header、首字节、读空闲和整体 deadline,并限制每主体并发上传数。
若下游对象存储变慢,读取请求体的速度必须随之下降,或写入有界磁盘缓冲;不能无限排队内存块。异步非阻塞读取要在 isReady() 时推进,并把当前 boundary 匹配状态、已收字节和摘要上下文保存到请求状态机。取消时按固定顺序停止下游写入、关闭输入、释放配额、删除临时文件、完成请求。
上传接口还要处理“字节已保存、业务事务失败”和“业务记录已提交、对象发布失败”两个裂缝。可先创建 PENDING 上传记录,成功发布后切 READY;后台清扫过期 PENDING 与孤儿对象。仅依赖请求 finally 删除会漏掉进程崩溃,因此临时文件名要携带不可猜 id 与创建信息,并由独立清扫器按租约和状态表核对。
压缩包与文档解析扩大了攻击面
压缩后很小的文件解压后可能极大,必须限制展开总字节、文件数、层级和压缩比。归档条目名可能包含 ../ 或绝对路径,解压目标应规范化后验证仍位于受控根目录。图片、PDF、Office 文档和媒体解析器属于复杂供应链依赖,应隔离资源、限制 CPU/内存/时间,并及时升级漏洞。
不要在同步请求线程内完成耗时转码和病毒扫描。上传完成后返回 operation id,由隔离任务推进 SCANNING、READY 或 REJECTED;未通过扫描的对象不可获得公开读取 URL。下载响应必须设置安全 Content-Disposition,展示名正确编码,同时避免 Header 注入。
观测与清理要证明资源收敛
指标包括 upload active、read rate、part count、bytes accepted/rejected、memory threshold spill、temp bytes/files/age、cleanup success/failure、scan queue age 和 client abort。验收不使用脱离容量的万能阈值,而检查压测停止后临时文件数量和字节回到稳定基线,失败与中断轮次不会单调增长。
日志只记录上传 id、主体、声明/探测媒体类型、大小、摘要前缀、状态和失败阶段,不记录文件正文、完整本地路径或敏感原文件名。故障时先判断失败发生在协议解析、大小限制、临时写、校验扫描、发布还是业务提交,再根据该阶段的所有者清理,避免一个笼统的“上传失败”掩盖磁盘泄漏。
下载、覆盖与断点续传共享对象版本
上传成功后读取入口只能暴露 READY 版本。PENDING、SCANNING 与 REJECTED 对象即使物理字节存在,也不能由猜测路径访问。覆盖同名业务文件时生成新版本,发布指针原子切换;旧版本按保留策略延迟回收,避免下载一半时底层对象被替换。
断点续传必须绑定 upload id、主体、分片序号、偏移、大小与摘要。只按文件名合并会把不同用户或版本拼在一起;只信任客户端总大小会让稀疏文件绕过磁盘配额。服务端维护已确认范围,拒绝重叠内容不一致和越界分片,最终按整体摘要校验后一次发布。
失败矩阵要覆盖进程边界:boundary 中间断流、单 part/总量超限、临时盘写满、扫描超时、数据库提交前后杀进程。每轮同时检查堆、文件句柄、临时目录与 PENDING 记录;只看到预期 HTTP 状态不能证明资源已经收敛。
