把客户端报价写入申请,谁替采购员确认过

采购申请的创建请求带商品编码、数量。浏览器也可以附带 unitPrice、total、tenantId,甚至声称“已审批”。如果服务把请求 JSON 直接映射到领域对象并持久化,既会接受伪造金额,也可能绕过状态转换。Web 请求的数据传输对象(DTO)要限制能表达的输入;校验需要保证输入形状;用例则按服务端商品目录重新计算金额,并在可信身份上下文中决定租户。三个关口任意一个缺失,都不能用其它关口的存在来推断安全。

基线是 Jakarta EE 11、Jakarta JSON Binding(JSON-B)3.0、Jakarta JSON Processing(JSON-P)2.1、Jakarta Validation 3.1、Jakarta REST 4.0 与 Java 21。当前 LabRequestsResource.java 的嵌套 CreateInput/ItemInput 是教学 DTO,但没有完整的字段白名单负例或正式身份验证。scenarios/09-lab-procurement.sh 的成功输入只证明这次合法 JSON 经服务端商品目录得到 13.75;非法价格、越权字段、跨字段规则的负例仍未运行。不要把一个有效 JSON 请求的 201 叫作“所有不可信字段均被阻断”。

DTO 与绑定器各承担哪一步

CreateInput 只声明 items,ItemInput 只有 sku 和 quantity,并没有价格、审批状态或租户 ID 字段。Jakarta REST 依媒体类型选择实体读写提供者;JSON-B 把合法 JSON 对象绑定到这个 Java DTO,再由资源方法把项转换成 ProcurementUseCases.ItemInput。工程没有调用 JSON-P,也没有显式要求绑定器拒绝未知 JSON 字段;不应把其它 JSON 框架的配置套用到这里。传入 role、tenantId 或 unitPrice 时,具体提供者如何处理未知字段须通过请求和实际构建版本实测;即使它忽略这些字段,也不等于应用严格拒绝越权输入。

另一方面,JSON-B 只是把字节转成对象,不会自动完成业务校验。正确的 Content-Type、合法 UTF-8 与合法 JSON 语法属于解析边界;缺少 items、quantity=0、sku 为空、重复同一个 sku 属于后续 DTO 或业务规则边界;币种、目录版本、采购额度、租户与审批身份则属于业务授权边界。CreateInput 的 public 字段是当前绑定示例,不是面向第三方的冻结公共 API;如果将来给 DTO 直接添 approved 并传到领域构造器,字段白名单就会失效。更稳妥的用例接口仍只接可信且必要的业务输入,不接受客户端传价格与最终状态。

“白名单”还要写进接口合同,而不是仅靠 Java 类成员数量。若规格要求对未列字段返回 400,就必须增加并验证显式未知字段拒绝策略;若规定忽略未知字段,也必须保证忽略后不会影响授权、日志与客户端的纠错体验。当前实现没有哪一种策略的专门测试,因此两者都不可标 PASS。嵌套对象还要覆盖 items:[null]、空数组、超长字符串、数量为 JSON 字符串而非数值、超大整数、浮点数溢出等路径。部分错误可能在 REST 提供者绑定阶段被拒,部分会走 Bean Validation,还有的可能落到应用的异常分支;未采集响应前不能为这些输入预填统一的 400。

字段白名单可以写成三个明确测试:一种是已知字段且值非法,例如 quantity=-1,应该在绑定成功后拒绝;一种是未知字段,例如根对象带 role="approver",要确认合同是拒绝还是忽略,且数据库不得因此变为已审批;另一种是已知字段有效但业务条件不允许,比如 sku 不在服务端目录里。第一种强调 Validation,第二种强调绑定策略,第三种需要用例与目录。对同一条请求,只在脚本中断言 HTTP 400 不够:若错误发生在数据库插入明细之后而事务未回滚,仍有残留 DRAFT。要按随机测试租户在独立连接查申请和明细行数;本地事务回滚已有另一归档证据,但没有覆盖这些 JSON 输入。

注解能检查局部形状,不能算业务金额

当前创建方法形参有 @Valid CreateInput input,items 字段使用 @NotEmpty List<@Valid ItemInput>;每项 sku 用 @NotBlank,quantity 用 @Positive int。Jakarta Validation 3.1 的级联校验需要 @Valid 才进入项对象;只在列表本身检查非空,不会自动检查每项 sku。@Positive 会让缺失数量绑定为基本类型默认值 0 时不满足条件,但字段缺失时绑定阶段具体表现仍取决于 JSON 解析/绑定;不能只靠 Java 默认值预测 HTTP 状态。@NotBlank 处理空白字符串,但它不验证 sku 是否在目录里,更不会自动设置价格。

资源类里的 itemInputs(input) 对 input == null、input.items == null 主动抛 BadRequestException,随后对每项调用 item.sku、item.quantity。若某个元素为 null 且容器级联校验没有在进入方法前拦住,访问该元素可能抛空指针异常;源码没有明确写出所有输入的错误映射。新增负例必须记录是否进入方法、具体异常类型与对外状态,并确保数据库未插入半份申请。Jakarta REST 4.0 §7 规定与 Bean Validation 的集成及默认错误处理,具体输出格式和被该实现抛出的异常包装仍应从隔离容器的原始响应确认;“用了注解”不等于通过了错误路径验收。

跨字段条件尤需再上一层:items 非空、每项数量正数,仍可能总额超预算,或者两个 sku 重复造成金额计算重复。若业务要求“预算总额不高于上限”“数量与目录库存同时满足”,要在服务端取得商品目录/预算数据后验证,而非用单个字段的 @Positive 代替。把总额作为 DTO 可写字段再加 @Positive 更不安全:输入是攻击者控制的,正数也可以小于真实总额。实测的两项是 paper×2(每件 3.50)与 pen×3(每件 2.25),合计 13.75;这来自 FixedPriceCatalog.java 和 ProcurementUseCases.java,不是请求 JSON 提供的价格。金额由 BigDecimal 计算与表层 numeric(18,2) 约束共同守住数值形状,规则是否合法还要看状态和租户权限。

不同层对“缺值”的理解也未必相同。quantity 用 primitive int,没有显式的“未提交”状态,绑定器若给默认值 0,@Positive 会拒绝;改成包装类型 Integer 时,可能需要同时加 @NotNull,否则 null 对某些数值约束是合法输入。items 列表有 @NotEmpty,不会限制最多多少条;上千条合法 SKU 仍可能使数据库写入、内存对象构造和错误响应过大。应按采购限额加入项数、单项数量和总额的上界,分别用边界值及超界值实测;这些上界是业务和容量约束,不在当前工程实现。把这种风险与 String.length() 的字节编码问题混同,会让请求体大小限制藏在不可见的默认值里。

租户字段缺席,不意味着调用者无法伪造租户

当前 DTO 不接受 tenantId,但资源方法另从 @HeaderParam("X-Lab-Tenant") 读取租户,requireLab 只检查演示模式开启且头非空。持有网络访问权限的人仍能改写这个头;跨“演示租户”请求返回 404,说明给定头作为查询参数在该次测试中被 DAO 使用,不等于已经绑定到已认证账户。真正租户来源须由第 24–26 篇的身份边界提供,并在每个用例和查询上核对对象归属。即使未来把 DTO 的 tenantId 标为只读,如果服务继续相信可伪造的头,漏洞不会消失。

对于价格、租户与状态三类越权字段,验收应分别检查“请求是否被拒绝或安全忽略”“服务端行里的金额和租户到底是什么”“重复请求有没有新行”。一次非法 JSON 返回 400 只说明语法/绑定可能失败,不能替代越权字段在合法 JSON 中的测试。建议为每个字段只改变一个条件,使用独立随机演示租户,在专用库用 SQL 查询最终行数与金额;若想验证数据库 CHECK,再用隔离 SQL 插入负数做独立试验,别让 DTO 注解代替数据库约束。这个矩阵目前尚未写入工程脚本,状态为 NOT_RUN。

实验入口、通过与未覆盖

工程准备、专用数据库和 loopback 服务依 examples/javaee-enterprise/README.md。归档 writing-plans/javaee-enterprise/verification/20261004T062900Z-pg16-business/RUN.md 的顺序脚本退出 0;原始输出有创建 201,独立 SQL 查询 ORDERED|13.75|1。其中只有合法 JSON 输入及服务端已知目录计价路径 PASS,没有坏字段的 HTTP 输出。最近的 20261004T-a-batch-recheck 目录又记录了业务脚本归档后的构建/部署摘要与成功重跑;该额外回归也未加入本章负例。两种证据都不能使“空值、负数、越权字段”变为已测事实。

可复跑现有正常路径:

1
2
3
cd examples/javaee-enterprise
JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab \
JAVAEE_LAB_PASSWORD='<本机专用口令>' bash scenarios/09-lab-procurement.sh

失败用例须补实际请求及断言:无 items、空数组、quantity=-1、sku 全空格,预期不应产生新申请;合法商品加 unitPrice=0.01 与 status=APPROVED,必须核对服务端重新计算后无越权状态,而“是否直接 400”仍由将来明确定义的白名单策略决定;伪造 tenantId 且改变 X-Lab-Tenant,不能借前者绕过真实身份。负例需逐项保存 HTTP 状态/响应体和独立连接最终行数,当前均 NOT_RUN。不要在日志中输出真实身份凭证或业务生产金额。更多矩阵见 10实验说明。

负例设计还须顾及错误显示方式。给客户端返回“商品不存在”可以引导纠错,但把未知 SKU 原样拼进 HTML、JSON 错误正文或服务器日志,会引入注入与日志污染风险;从 JDBC 异常中直接抽取 SQL 和数据库用户名则泄漏内部结构。建议让资源入口把可以公开的字段路径与错误码稳定化,保留有权限的请求 ID 与原始异常用于受限服务器日志;遇格式错误、校验失败、业务错误时分别记录在哪一层被拒。当前 ConflictMapper 与 MissingMapper 只返回状态,没有通用校验错误对象,也没有多语言消息格式;本篇不虚构一个统一 {code,message} 响应。要判断 API 是否适合外部客户端,必须补一次针对各失败输入的 HTTP 原始输出采集。

还有跨字段的一致性校验问题。假如前端同时传纸张数量与预估总额,即使分别都是正数,也不能保证乘积匹配目录价格;若要求“审批人不得审批自己创建的申请”,仅校验 approverId 非空也无法确认两个主体不同且确实存在。规则所需的身份、价格版本、采购额度应由服务端加载。把这个判断写在 DTO 的自定义校验器里仍然要明确它查询数据时所处的事务与授权上下文;最小版本可在用例中计算并拒绝,而不是在 Web 层复制一套预算计算。当前工程尚无这类预算规则,故这个反例是演练设计,不是既有实验 PASS。

两道练习与答案

练习一: 客户端发送 items=[{"sku":"paper","quantity":2}],还附 total=0.01、status="APPROVED"。当前受控接口如果成功创建,数据库中的价格与状态应由谁决定?是否已经证明请求带这两个额外字段会得到 400?

答: 用例从服务端目录查 paper 单价 3.50,以数量 2 算 7.00,新申请由领域规则定为 DRAFT;DTO 并没有总额或状态字段。但 JSON-B 对未知字段的当前容器策略尚未实测,不能断言必然返回 400。须另外发有效 JSON 加未知字段,记录状态、库中状态/金额,再决定严格拒绝还是安全忽略的合同;若输入失败,不能拿该次 400 反证服务器重算。

练习二: DTO 的 tenantId 字段被删掉,但调用者把 X-Lab-Tenant 从 A 改为 B,再请求 B 的申请。是否可以宣称租户隔离成立?怎样补齐跨字段和对象权限两个检查?

答: 不能。演示头仍由调用方自行填写;DTO 白名单与身份可信度是两回事。用认证主体得出租户,从业务用例到 JDBC 都以该主体约束对象;另外设计总额、重复 sku 等跨字段规则,并给出两条不同认证主体交叉访问 B 对象的拒绝断言与数据库终态。当前工程未实现真实主体,相关安全实验 NOT_RUN。

若要给这一入口补自动化测试,纯 Java 单元测试可以直接构造 DTO 验证业务换算,但不能覆盖 JSON-B 的字段绑定与 REST Validation 的异常映射;容器 HTTP 测试可覆盖绑定与错误返回,却仍需要独立 PostgreSQL 查询排除半写入。安排三层独立断言比一个“校验器抛异常”更能定位故障:字节输入能否正确解码、有效 DTO 是否经用例按服务端价格计算、异常后数据库是否回滚。每次改 DTO 字段与序列化配置,要同时回归合法输入和额外字段负例;只编译通过不会告诉读者服务是否悄悄接受了新的可写状态。

有界输入还应覆盖幂等语义:两次同样的 JSON 不是同一笔采购申请的可靠证据,因为相同 sku 与数量可能分别属于两次真实采购。若产品要求重试不重复创建,客户端必须携带稳定的请求标识,服务器在数据库中持久记录与该标识对应的创建结果。DTO 的字段校验可阻止非法标识格式,却不能代替并发唯一约束或提交结果查询;本工程的创建入口未实施这一协议。

限制与官方资料

JSON-B、JSON-P 和 Validation 分别处理数据绑定、JSON 处理模型和约束验证,不能相互代替,也不自动保证供货目录、预算或身份可信。教程使用的演示头与固定价格目录不适合直接部署到真实采购环境;没有未知字段拒绝、数组中 null、超大数量、异常映射和真实权限的原始证据。GitLab master 路径在源码推送后必须核对是否与归档哈希一致。