MIME、权限、泄漏、清理与生命周期:文件为何成为安全和成本风险
用户上传的文件会经过多次使用:接收、保存、解析、预览、分享、生成缩略图,最后删除。每一步接触的内容和权限不同。上传时允许保存的原始附件,未必适合放到业务域名下直接预览;删除源对象之后,派生文件和缓存也可能继续提供内容。
文件安全因此需要同时处理输入、执行环境、访问授权和存储寿命。类型检查能尽早拒绝不合要求的输入,资源预算限制解析代价,业务记录则让后续读取和清理有明确依据。
从类型声明到允许的业务用途
四种信息需要分别判断
| 信息 | 来源 | 能用来做什么 | 仍需检查什么 |
|---|---|---|---|
| 扩展名 | 用户提供的名称 | 按业务允许清单做低成本筛选 | 大小写、双扩展名、特殊字符与实际内容 |
| Content-Type | HTTP 请求头或对象属性 | 选择预期解析方式、初步拒绝错误请求 | 客户端可伪造该声明 |
| 文件签名、magic bytes | 字节前缀或结构探测 | 识别常见格式特征 | 多格式文件、截断内容与嵌入载荷 |
| 完整解析结果 | 专用解析器 | 检查结构是否可接受、提取内容 | 解析器漏洞、资源耗尽和业务用途限制 |
PNG 头像接口可以同时检查文件名、声明类型和实际格式,在像素数量受限的前提下执行解码。保存任意附件的系统则可以不做预览,只通过隔离域名以 attachment 提供下载。
检测出格式后,还要按用途决定是否允许执行、预览或转码。这些操作接触的解析器和权限不同,需要各自的检查。
只允许清单内的格式通常比列举所有危险扩展名更容易维护。攻击者也可以把 HTML 改名为 jpg,或提交可被不同解析器以不同方式理解的多格式内容。需要统一预览时,可在受限环境中解析并重新编码为允许的输出格式;这样能减少原始元数据和附加内容,但仍需维护解析器和转换组件。OWASP 文件上传防护
原始文件名只用于展示
存储路径由服务生成的 ID 决定。用户名称保存在独立字段,输出到网页时做 HTML 转义,写到响应头时拒绝控制字符并按协议编码。不要把上传名拼进 shell 命令、文件路径或数据库语句。
下载名称可以通过 Content-Disposition 的 filename 和 filename* 表达,但路径分隔符、特殊名称和控制字符仍需要过滤。服务选定的安全展示名,不应让浏览器保存到用户指定之外的路径。RFC 6266
存储目录与 Web 静态目录应分离,防止上传内容被应用服务器当作脚本或配置文件执行。格式校验通过也不授予这种执行权限。容器中使用非 root 身份、只读程序目录和单独的可写暂存卷,可以缩小意外写入的范围。
上传成功之后还可以保持隔离
一种常用处理顺序是:先保存到不可公开读取的隔离区,记录长度和内容摘要,执行扫描与必要的格式转换,检查业务引用,再把可发布结果登记给下载服务。原始文件与转换产物用不同对象名,避免已签发的上传地址覆盖后来审核通过的内容。
如果需要恶意软件扫描,扫描返回应区分发现威胁、未发现威胁和扫描失败。超时、特征库不可用、文件无法读取或资源超限时,保持隔离并记录失败原因;不能把“扫描器没有返回威胁名称”当成扫描成功。ClamAV 的 clamscan/clamdscan 提供真实扫描入口,但命令行参数、守护进程权限和返回结果需要按部署方式处理。ClamAV 扫描文档
解析器和扫描器使用不同的风险控制:限制 CPU、内存、进程数、临时空间、网络出口与处理时长,不授予业务数据库和整桶删除权限。PDF、办公文件中的外部引用或宏,不能因为扫描通过就被允许在高权限环境中执行。规则升级后,已保存文件可能需要重扫,记录扫描引擎和规则版本有助于定位需要重新处理的对象。
归档与图像怎样在有限预算内处理
真实文件安全实验
下载 文件安全实验工程,解压进入 file-safety-lab。环境为 Linux、Docker 和拥有 Docker 权限的普通用户;Java 17 编译运行,也可在 Java 25 对照。实验使用 JDK 的 ZipInputStream、ImageIO,以及 H2 2.5.250 文件数据库。
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" --entrypoint mvn \
-e MAVEN_CONFIG=/m2 -v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Dmaven.repo.local=/m2 clean verify预期八个测试全部通过。它们实际生成 ZIP 和 PNG,调用解析器和文件系统,验证正常解压、路径穿越、目录条目夹带载荷、展开超限、重复目标、条目数量、图像像素和数据库删除恢复。测试失败时 Maven 非零退出;不要继续使用没有通过的处理结果。
这些检查只覆盖明确的输入格式和资源约束,未实现病毒引擎,也不构成对所有解析器漏洞的证明。
ZIP 先确定私有目标,再限制每条展开路径
ZIP 内的条目名称属于不可信输入。对 ../outside、绝对路径或 Windows 风格特殊路径,应在创建文件前拒绝。每次解压创建独立私有目录,把条目解析为目标路径并 normalize,再确认其仍位于这个目录下。
Path target = output.resolve(entry.getName()).normalize();
if (!target.startsWith(output) || target.equals(output)) {
throw new IOException("PATH_REJECTED");
}完整实现还拒绝空名称、反斜杠和冒号,记录已经使用过的规范化目标,使用 CREATE_NEW 创建文件。两个不同条目名若规范化到同一路径,同样判为冲突。示例把普通条目写成普通文件,不恢复归档中的可执行权限或符号链接属性。
字符串路径检查不能解决所有本机竞争条件。如果不可信的本地进程能够替换目标目录为符号链接,它可能在检查和创建之间改变路径含义。实验使用当前任务独占目录,不允许其他写入者;敌对共享环境需要更严格的目录句柄操作和隔离措施。
累计实际展开字节,不能跳过未计数的载荷
压缩包只有几 KiB,并不代表展开后也小。ZipEntry.getSize() 可能未知或来自攻击者可影响的元数据,所以真正的限制要放在每次读取解压字节之后、写入目标之前:
byte[] buffer = new byte[8192];
for (int n; (n = zip.read(buffer)) != -1;) {
if (n > maxBytes - expanded) {
throw new IOException("EXPANDED_LIMIT");
}
expanded += n;
file.write(buffer, 0, n);
}expanded 是整个归档的累计值,不能每个文件重新归零。还要限制条目数、单条目大小、目录深度、嵌套归档层数和总处理时间。数百万个空文件即使展开字节很少,也会耗尽 inode 和目录操作时间;高压缩比之外的复杂输入也可能消耗大量 CPU。
一个常被遗漏的分支是目录条目。恶意 ZIP 可以让名字以斜杠结尾,同时附带大量压缩数据。如果代码看到 isDirectory 后直接 continue,下一次 getNextEntry 可能先消耗上一条目剩余数据,绕过应用在文件复制循环里的字节预算。
实验要求目录条目没有载荷:读取一次,若不是 EOF,就立即拒绝整个归档并关闭流,不再继续枚举。
if (entry.isDirectory()) {
if (zip.read() != -1) {
throw new IOException("DIRECTORY_PAYLOAD");
}
Files.createDirectories(target);
continue;
}对应测试构造了一个目录条目,内部有 1 MiB 可高度压缩的数据,要求返回 DIRECTORY_PAYLOAD,并确认没有遗留解压目录。普通大文件则触发 EXPANDED_LIMIT。条目读取与关闭的具体行为见 JDK ZipInputStream。
发现异常后,实验删除本次生成的私有目录;清理失败以 suppressed exception 附加到原异常,保持最初的拒绝原因。生产上同时记录待清理位置,并让后台任务重试。清理目标只能是已确认属于本次任务的目录,不能按用户输入的路径递归删除。
图像在解码前先检查像素规模
图像文件压缩后很小,展开为像素矩阵却可能占用大量内存。以四字节像素粗估,20,000 × 20,000 已约 1.6 GB,尚未计算解码器、颜色空间转换和派生图像。
实验通过 ImageIO 识别实际 reader,要求格式为 PNG,读取宽高并以 long 计算像素数,在超限时停止;随后实际调用 read 验证图像能够解码。正常测试是 20 × 10 像素,最大 200 像素时通过、最大 100 像素时得到 PIXEL_LIMIT。把 ZIP 交给图片入口则得到 IMAGE_FORMAT。
元数据读取本身也经过解析器,因此像素判断之外仍需整体时限和进程资源限制。多帧图像还要限制帧数及累计像素,缩略图生成要限制目标尺寸,避免允许用户要求无限放大。解析后的原始图片、缩略图和转码产物应各自有可追踪的存储记录。
文件读取如何隔离权限与泄漏
每次读取都根据业务关系授权
下载接口接收 fileId,由服务查询当前租户、所有者、业务引用和状态,再得到存储定位。不可猜测的随机 ID 能降低枚举便利性,授权仍必须执行。缩略图、预览、下载原件和导出错误文件也都属于读取入口,不能只保护其中一个。
共享文件需要单独表达被授权的人、操作和期限。公共分享链接可以使用受控分享记录,在服务端检查是否撤销;如果最终返回预签名对象 URL,还要考虑该 URL 在原有效期内的后续访问。对象存储 SDK 与临时 URL给出了重复使用和撤销的实际实验。
跨租户去重需要格外谨慎。即便两份文件摘要相同,租户的权限、保留要求和删除请求也不同;不要通过“文件已存在”的响应暴露其他租户是否上传过同一内容。共享底层对象时,引用和保留规则要覆盖全部所有者,一个租户删除自己的引用不能删除其他租户仍使用的字节。
上传内容不要继承业务站点身份
允许浏览器直接解释的 HTML、SVG 或其他活跃内容,若放在业务主域名下,可能影响同源页面和 Cookie。更稳妥的附件访问使用独立内容域名,避免携带业务登录 Cookie;无需预览的文件以 attachment 提供。
响应同时设置准确 Content-Type,并使用 X-Content-Type-Options: nosniff 减少浏览器类型猜测。确需在线预览时,使用经过限制的转换结果,并针对内容配置 CSP 或沙箱。一个响应头无法替代格式检查和隔离部署;已有前端域名、代理和 CDN 也必须保持一致配置。OWASP 文件上传防护
日志中避免完整预签名 URL、长期密钥和原始内容;访问日志中的查询字符串要按敏感参数脱敏。文件名本身也可能包含姓名、编号或内部项目名称。排查时通常需要的是 fileId、步骤、大小、错误码和存储请求 ID,而不是整份内容。
派生文件和缓存也要参与授权
原始对象删除后,缩略图、转码文件和 CDN 仍可能命中。给每个派生产物保存源版本和处理版本,源对象变更或被隔离时,能够停止使用对应产物并清理缓存。
私有下载还要检查 CDN 的鉴权位置和缓存键。即使源站每次鉴权,CDN 对不同用户复用同一缓存响应仍会绕过应用判断。验证时应覆盖缓存命中和未命中两条路径,同时检查下载链接时效与源站授权。
保留、删除和失败恢复怎样落到存储
一次删除涉及哪些副本
业务文件记录
├─ 业务引用 订单、消息、工单等仍在使用的关系
├─ 源对象 固定 key / version
├─ 派生对象 缩略图、转码、预览和索引副本
├─ 在线缓存 CDN、代理、本地缓存
├─ 历史版本与未完成上传 对象存储各自的生命周期
└─ 备份与保留副本 独立的到期、恢复及限制规则业务删除可以先使文件不可再访问,再异步回收存储。物理删除完成的范围应明确:删除在线源对象不表示已经清除历史版本、离线备份和用户下载副本。保留或法律冻结要求需要由业务确定;存储执行层必须检查对应标志,并为不允许删除的情况返回明确结果。
对象存储生命周期适合按对象条件处理到期数据,但它并不了解数据库里哪个订单仍引用对象。版本化桶的过期规则还可能只创建删除标记,历史版本需要单独规则;未完成 multipart 上传也有独立处理方式。S3 对象过期说明
因此常见组合是:业务记录决定能否删除以及必须保留什么,清理程序执行具体对象和派生文件回收,存储生命周期处理受控前缀的兜底过期。备份恢复后还要重新应用删除与保留记录,避免把已删除内容重新发布。
用持久化状态挡住新的引用
下面的实验使用真实 H2 数据库和本地文件,复现“对象已经删除,数据库还没登记完成”的窗口:
docker run --rm --user "$(id -u):$(id -g)" \
-v "$PWD:/work" -w /work eclipse-temurin:17.0.20_8-jdk \
java -cp 'target/classes:target/dependency/*' \
dev.example.files.FileSafety lifecycle work预期输出:
delete recovery: source/derived absent; DELETED; next file deletion succeeded程序创建一个源文件和一个派生文件,把 READY 记录写入数据库。清理先锁定记录,确认引用数为零且 held 为 false,把状态改成 DELETING 并提交。新增引用使用带状态条件的更新:
UPDATE file_record
SET refs = refs + 1
WHERE id = ? AND state = 'READY';这样清理提交 DELETING 后,新引用更新影响零行,调用者知道文件已经不能再附加。仅先查一次“没有引用”,再慢慢删除对象,会给并发新增引用留下窗口。
实验只包含 READY、DELETING、DELETED 及引用/保留字段。create 方法负责生成测试文件,并非完整生产上传发布协议。生产还需把暂存、扫描、发布和孤儿对象对账纳入文件创建过程;设置保留、增加引用与清理领取都要共享数据库并发规则,不能绕过状态条件直接改表。
已经删掉的文件可以再次确认
清理使用 deleteIfExists 删除源文件和派生文件。两者删除后,实验抛出精确的 INJECTED_AFTER_OBJECT_DELETE,保留数据库中的 DELETING。此时验证新增引用失败,再次执行清理:本地文件已经不存在,删除仍可继续,最终把记录更新为 DELETED。最后处理另一份新文件,确认异常没有遗留事务或阻塞后续清理。
对象存储 DELETE 同样需要分析结果未知:网络超时后查询固定 key/version 是否仍存在,不能把客户端超时直接写成完成。批量删除 API 还可能部分成功,要读取每个对象的结果。删除权限不足、保留锁拒绝与对象不存在,应分别处理。
生产清理任务通常增加领取者、租约和版本号,多个工作者用条件更新争取任务。租约过期后可以重新领取,但旧工作者可能仍在运行;需要通过固定不可复用对象名、幂等操作和版本条件避免旧任务删除新内容。记录变更后再次执行检查,不能长期持有一份过时的引用快照。
从资源和记录发现遗漏
| 现象 | 需要检查的对象 | 合理处理 |
|---|---|---|
| 上传完成后扫描一直不结束 | 隔离状态、扫描超时、队列和规则版本 | 保持不可读取,按失败类型重试或人工处理 |
| ZIP 很小却消耗大量资源 | 实际展开量、目录载荷、条目数、嵌套和时间 | 拒绝并关闭整次解析,回收私有目录 |
| 删除后旧链接仍可下载 | 固定源版本、CDN、派生产物、签名有效期 | 核对真正返回内容的位置,执行相应失效操作 |
| DELETING 长期积压 | 删除权限、保留锁、数据库回写、重试次数 | 保留记录并恢复任务,不能直接批量改成 DELETED |
| 存储持续增长但业务记录很少 | 未完成分片、孤儿对象、历史版本、预览副本 | 分类型对账,设置有范围的兜底回收 |
| 清理误删活跃文件 | 引用是否原子更新、对象名是否被复用、任务版本 | 立即停止该清理路径,恢复内容并修正并发规则 |
清理脚本执行前先列出候选、归属和数量,给每批设置上限,保留删除结果。陌生路径或归属不明的对象进入待核对队列。当前查询结果中缺少记录,只能触发进一步查找,还不足以批准删除。
容器随命令结束移除,work 中的数据库和对象目录仍可用于核对删除结果。清理实验文件时也按同样的归属要求操作:仅处理本次生成、已经确认不再使用的目录。
