Java EE 企业应用 29:附件与导出在哪些资源边界失败
一张审批单上的报价文件不能按文件名取
采购员上传报价附件 supplier-quote.pdf;另一个租户猜测 URL 为 /files/supplier-quote.pdf。若下载只检验文件名,其他租户就能读到报价,即便采购详情查询的 SQL 已经带租户。文件、附件元数据与采购申请必须共享受信租户和对象权限。当前示例 WAR 没有附件元数据表、上传/下载/导出正式路由,也没有认证入口。本章只提出需要实现的服务与验收;所有上传、下载、断连及恶意输入实验为 NOT_RUN。
元数据建议用 tenant_id + attachment_id + request_id + storage_key + byte_count + media_type + state + uploader_id。storage_key 由服务器生成随机值,和客户端文件名脱钩;下载先验证 principal 对目标申请的读取权限,再按租户和附件 ID 查元数据,最后按服务器生成的存储键读对象。申请审批附件可能含供应商机密,要限制内容类型、按实际文件特征与业务策略校验、禁止将上传目录作为静态网页执行目录;杀毒/隔离若引入,则只能在扫描通过后标为可下载,不能宣称本章已有扫描能力。原始文件名最多用于经安全编码的下载建议名,不参与磁盘路径拼接。
Servlet 接入点、流和提交顺序
目标路由为 POST /api/requests/{id}/attachments,需要第 24–26 篇的受信身份与租户上下文。Servlet 6.1 的 @MultipartConfig(maxFileSize=..., maxRequestSize=...) 可以设置容器层的限制,真正读取时还要实施字节计数并在超限时删除临时文件;容器可能在进入 servlet 前就把 multipart 写入暂存位置,运行时的 temp 目录、磁盘配额和清理策略也必须配置。以下工具只有临时文件复制逻辑,不是上传 Servlet;不会进行授权、扫描或持久化。
1 | |
调用端须在受限私有目录用 Files.createTempFile 创建 temporary,把 limit 固定为服务端允许的正数,并在后续失败与客户端断连时也用 finally 清除未发布临时文件;此方法不关闭 input,由 Servlet/part 的生命周期负责。成功路径先核对请求权限、字节数和类型,写入服务器生成键,再把元数据及业务状态纳入数据库事务;文件存储和数据库不是天然同一事务,若数据库提交失败需要删除孤儿文件,若删除也失败需可靠清理任务。元数据提交前不能公开尚未完成或未经扫描的文件。断线可能发生在响应写出后:客户端没收到 201 不代表服务器没持久化,应使用上传业务幂等键/查询确认,不能盲目重传产生两个附件。
下载端需重新查当前访问权限,而不能仅依赖曾可上传;针对大文件以有界流写响应,不整文件放内存,Content-Disposition 用符合协议的编码/安全回退名,不拼客户端字符串到响应头,设置 X-Content-Type-Options: nosniff 和适当 Content-Type、Cache-Control: private, no-store(依敏感数据策略)。客户端中断要关闭输入流,不会自动撤销已授权的历史下载;记录脱敏下载事件,不记录附件内容。反向代理和磁盘配额限制也应计入边界。
CSV 是文本文件,却可能执行公式
采购导出先按受信租户/主体过滤 SQL,再固定列次序和稳定排序,分页/流式读取,不把所有申请留在内存。对字段中逗号、双引号、换行按 RFC 4180 加引号并将内部双引号加倍;这只能保证 CSV 结构,不足以阻止表格公式注入。若面向电子表格,将用户可控单元格(含前置空白、制表符、换行的变体)规范化检查;以 =, +, -, @ 开头的值按目标客户端策略前置单引号,随后做 CSV 引号转义,同时记录这是为“表格打开”而非原始数据交换设计的损失性展示编码。保留原始数据供审计查询,别在数据库里破坏供应商名;更严格需求可导出不执行公式的格式。不同表格客户端对控制字符/引号的处理须实测,不能只看文本输出。
一正三反的隔离实验
完成前置身份与新路由后:A 的采购员上传一份允许类型的小附件,读回字节与元数据;上传超过限制应拒绝并确认私有目录无残留;A 用 B 的附件 ID 下载或导出,既不能取得响应内容也不能更新 B 的行;用文件名 ../../outside 上传,确认它未影响存储路径;中断下载和上传,核对打开文件描述符、暂存及幂等记录;导出合成字段 =1+1,在目标表格客户端确认只显示文本。相应路由不存在,正常、超限、越权、断连、CSV 公式用例全部 NOT_RUN;GET /api/health 或 JDBC db-check 无法代替文件与导出测试。
把文件归属写进元数据,而非从路径推断权限
即使以随机 storage_key 命名对象,“知道对象地址的人能访问”也不是采购系统可接受的授权规则。下载时先从容器身份和组织关联得到可信租户,再在数据库用 attachment_id + tenant_id 查元数据并关联申请当前可读权限,只有元数据状态为可用时才使用存储键打开文件。被撤销访问权的采购员即便记住了旧附件 URL,也应被拒绝。原文件名不应成为对象存储键、路径片段或租户判断依据;攻击者完全可以上传叫作另一个租户常见报价名称的文件,扩展名和声明的媒体类型也都由客户端控制。
元数据设计若要在数据库层防“附件自称租户 A,却挂到租户 B 的申请”,可以在第 26 篇拟议的 (tenant_id,id) 唯一约束之后加 (tenant_id,request_id) 复合外键。目标结构的独立迁移示意,NOT_RUN如下;它不包含真实文件内容,也未接入当前 001-initial.sql:
1 | |
三个租户参数均来自同一受信上下文或受约束的服务内部传递,不能从上传 JSON 和 URL 各取一份。SQL 命中只说明文件在相应租户并处于可用状态;若同租户内还需校验申请人、审批员的对象权限,须把授权条件补到受管用例,不能把 READY 理解成所有同租户账户都可读。复合外键保证父子归属匹配,storage_key 唯一防两条元数据记录误指同一对象,并不会自动删除磁盘上的孤儿文件。
上传流程不能让暂存文件先变为公开对象
正常的上传从认证与申请写权限检查开始;再进行大小和类型检查、将输入流写到服务端创建的私有临时文件、检查内容特征,必要时放入隔离扫描;只有满足业务策略才将生成的存储键与元数据置为 READY。在处理恶意文件时不能假设 Content-Type: application/pdf 或 .pdf 扩展名足以证明文件安全,也不能让任意媒体类型在站点静态目录以浏览器可执行的方式公开。对正在扫描的文件保持 PENDING,下载端只接受 READY;扫描服务不可用时保留受限状态并记录任务,不能无声放行。
容器解析 multipart 时可能已经把上传内容写到配置的临时目录,Servlet 方法内 Part.getSize() 再拒绝超限只是第二道检查。要同时限制代理最大请求体、Servlet maxRequestSize、单文件 maxFileSize、同时请求数、暂存磁盘配额和业务字节计数;任何一层超限都检查临时文件与部分元数据是否被清理。现有 BoundedUpload.copy 在读取时计数并对它遇到的 IOException 删除指定临时文件,但若成功复制后校验类型或写元数据失败,清理由调用者在 finally 完成。直接返回这段工具代码作为“上传功能已实现”,就遗漏了身份、目录访问、提交协调和后续故障。
文件系统与数据库不是同一个原子提交域。先提交元数据、再写文件,进程在中间退出可能留下 READY 却找不到真实文件;先写文件、再提交元数据,可能留下私有孤儿文件。可采用私有暂存加 PENDING 元数据、写入并校验文件后再发布文件和更新状态的分阶段协议,但“文件发布”和“数据库事务提交”仍存在崩溃窗口,不能说一次 Files.move 使两者变成一个事务。移动是否支持原子性还取决于同一文件系统及实现;若不支持应有明确失败分支。恢复任务应周期性比对暂存文件、对象和元数据状态,隔离未引用对象并在保留期后清理,切勿因 HTTP 请求断开就把可能已提交的上传当作从未发生。
下载中断和导出被打开时的结果各是什么
下载审批发生在响应写出之前:先完成身份、对象权限、文件状态的检查,再打开私有流并决定 Content-Type、Disposition 和缓存策略。如果边读边写时客户端断线,应关闭流及相关资源,但历史下载审计和已经传送的字节不会被“回滚”。发送部分内容后再尝试改写 HTTP 403 或 500 通常已经来不及;服务端日志应保留相关标识、已发送字节计数、错误位置,不能把一个已开始发送的响应计为“完整成功”。若未来支持 Range 请求,还须明确分段授权、内容长度和缓存策略,不应以一个无需身份的静态目录绕过受管校验。
CSV 导出先从授权过的申请集构造稳定排序的结果,逐页或流式读取时保持一致的筛选条件;若请求持续很久,期间租户资格撤销应如何生效,要定义快照/取消规则。对用户名、供应商名、备注等可控列,先按面向目标表格客户端的公式安全策略处理值,再按 RFC 4180 对逗号、双引号和换行转义;引号保护 CSV 结构,却不会让 =1+1 停止被某些表格程序当公式解释。对前置空白、制表符和换行变体以及 +、-、@ 同样要在冻结客户端验证;前置单引号也可能被不同客户端当成可见字符或在重新保存后丢失,不能仅凭文本查看器的显示判定安全。对不是给表格使用的机器 CSV 接口,这种损失性展示处理还须与原始数据交换用途区分,不可悄悄改变下游所需的原值。
能复跑的环境条件和故障命令
正式实施后,先准备合成租户 A/B、带报价的受限申请、可信测试账户、私有存储目录及带证书的 HTTPS 部署。正常上传应使用只含模拟数据的文件,在后续用受管下载路由按字节哈希对照;以下命令是尚未存在的上传路由的接口合同,NOT_RUN,要求 $TEST_PDF 是测试专用的真实小型 PDF,$BUYER_COOKIE_JAR 是隔离认证会话:
1 | |
第二个文件应在隔离环境准备成超过部署限制的内容,服务端应拒绝并清理暂存。若使用 Cookie 会话,第 27 篇的 CSRF 策略也必须接入并随请求发送令牌;否则两个请求都可能因为缺令牌被拒,这不能证明上传大小检查起作用。再以 A 的认证会话请求 B 的附件 ID,核对 403/404 和数据库/存储最终状态;用文件名 ../../outside 的请求只验证路径键始终由服务器生成,不能以“不存在文件”替代目录穿越试验。确定性的断线需要代理或可控 stub 在指定字节数中断连接;简单设置 curl --max-time 可能在传输结束后才超时,不能作为可靠故障注入。原工程没有上述路由和元数据表,不要把这些命令对健康端点的输出伪装成本章证据。
同一份附件的正常回读可用上传前后的 SHA-256、字节数、类型及受限申请 ID 四项核对,而不能只看 HTTP 200:有些 200 返回的是应用错误页面,不是原文件。若代理支持断点下载,分段请求仍应经过与全量下载相同的对象许可,返回的 Content-Range 和字节范围要与原文件一致;不支持分段时应明确拒绝而非绕过权限走静态文件路径。对 CSV,也应把文件的列数、行数、租户筛选范围和表格程序中公式是否执行分别记录,不能凭下载文件非空就认为导出安全。上述路由与客户端验收目前均 NOT_RUN。
上传限制、跨租户下载、断连与 CSV 客户端检查的复验清单见附件和导出实验卡;完整路由未部署。
两道有解的练习
练习一: 上传限制 10 MB,代码检查 Part.getSize() 后用原始文件名调用 Path.resolve(name)。哪两处仍有风险?
解: 容器可能先写完暂存文件才进入检查,需配置请求体与暂存目录配额,并在流式读取处再次有界计数;文件名可能含路径穿越、分隔符或头注入,不能用作存储键。使用服务器生成的独立键,失败时清理临时文件,权限检查先于发布,断连后核对数据库与磁盘状态。这里没有运行超过限制的请求。
练习二: A 的列表导出 SQL 带 tenant_id=A,但下载接口按全局 attachment_id 读元数据。能否宣称租户隔离?如何处理供应商名 =1+1?
解: 不能;下载须使用认证主体到租户的受信映射,同时核对附件及所属申请的权限。导出字段先按目标客户端的公式安全策略将其当文本处理,再执行 CSV 引号规则,并在目标表格工具验证结果;SQL 参数化、CSV 格式化与表格公式安全不是同一个校验。以上均为待验证用例。
版本与依据
教学组合为 JDK 21、Jakarta EE 11、Open Liberty 26.0.0.5 与 PostgreSQL 16;规范入口:Jakarta Servlet 6.1、Jakarta Security 4.0、Jakarta EE Platform 11;CSV 格式参见 RFC 4180,Java 临时文件 API 见 Java SE 21 Files。容器暂存、浏览器下载和表格客户端行为均须在冻结环境重新验证,不能由 API 文档代替运行记录。





