Ver código fonte

fix(进件): multipart 守卫拒 null - 与 CR/LF 同族的不可表示输入

requireHeaderSafe 里那句 `if (value == null) return;` 改成拒绝:同一个
IllegalArgumentException,消息点名入口 + 说明是 null,不带任何值。三个入口
(fileFieldName / formFields 的每个键 / fileName)一致 —— 它们都走同一个守卫。

★ 先测再写(这条是本次裁定的正当性依据,不是推测):把 null 分支临时改回 return,
用同一套捕获线上字节的装置跑出三个入口的真实报文,三例**都不是异常,而是静默改形状**:
- fileName == null       ⇒ Content-Disposition: form-data; name="img_file"
                           (filename 段消失、Content-Type 从 image/jpeg 退化成
                           application/octet-stream)—— 文件部件变成一个普通表单字段;
- fileFieldName == null  ⇒ Content-Disposition: form-data; filename="a.jpg"
                           —— 连 name 段都没有;
- 表单键为 null          ⇒ Content-Disposition: form-data(无名无 filename),且该部件
                           不进签名原文(签名与不含该键时逐字相同)⇒ 线上多出一个既不具名、
                           又不被签名覆盖的部件。
fileName 尤其真实:它一般来自 MultipartFile.getOriginalFilename(),那个 API 契约本就可空。

javadoc 同步:守卫段写明「两族不可表示输入」(ISO 控制字符 + null),@param
fileFieldName / fileName 补「不得为 null」,@throws 一并更新;并把「值不校验」的推理链
改写成**位置**判据(值出现在部件头块结束的那个空行之后),附一句「字节在不在报文里
推不出字节在不在头部」—— 值里的 CR/LF 原样在报文里,子串判据会得反结论。
明确不在本层拒空串 / 全空白:那是上游侧校验的活(T6 收 ⇒ 400)。

验证:LeshouyingClientMultipartTest 17 条全绿(14 + 新 3);全量 448 通过(445 + 3,
42 个 suite,PaymentApplicationContextTest 实跑 7.42s ⇒ 库隧道在位)。
两向变异:M-G 把 null 分支改回 return ⇒ 3 条新拒绝用例红(且只有它们红,异常类型
退化成 LeshouyingApiException);M-H 无害(null 检查与 isISOControl 循环对调,写成保持
等价的形式:循环包在 value != null 里、null 检查挪到其后)⇒ 17 条仍绿。两次变异都实测
重编译(435 个主源文件)⇒ 变异体确实生效。还原实测:客户端 sha256 回到 deb4c7e2…
alphaH 3 semanas atrás
pai
commit
b4df0d612b

+ 56 - 16
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java

@@ -132,20 +132,46 @@ public class LeshouyingClient {
      * 上游按它自己那份参数集重算,必然对不上,而失败只会表现为一句笼统的「签名错误」。
      * 本方法不开任何旁路:签名仍走 {@link LeshouyingSigner},与另外两条腿同一套规则。
      *
-     * <p>【前置条件:三个**会进部件头**的入口拒绝 ISO 控制字符 —— 这是**我们**的约束,
+     * <p>【前置条件:三个**会进部件头**的入口拒绝「头部无法表示」的输入 —— 这是**我们**的约束,
      * 上游文档(page 196236572)**没有**提这件事】
-     * 实测(真实 {@code FormHttpMessageConverter} + 捕获线上字节):{@code fileFieldName}、
-     * {@code formFields} 的**键**、{@code fileName} 三个值都会被原样写进部件的
-     * {@code Content-Disposition} 头,里面一个 CR / LF / tab 就能凭空多出一行头部
-     * (线上一字不差长这样:{@code filename="a\r\nX-Evil: 1.jpg"} ⇒ {@code X-Evil: 1.jpg"}
-     * 成了部件头块里的独立一行)。而 {@code formFields} 的**值**写在部件**体**里、不参与头部,
-     * 故**不**校验。三处任一含控制字符 ⇒ 抛 {@link IllegalArgumentException},
-     * 且**在任何出站之前**(连实体都不构造,一个字节都不出海)。
-     * 是**拒绝**不是清洗:清洗会让「客户端说发了 X、上游收到 Y」,而那道缝隙正是注入要利用的;
+     * 两族输入、同一个 {@link IllegalArgumentException}、同样**在任何出站之前**(连实体都不构造,
+     * 一个字节都不出海):
+     * <ol>
+     *   <li>**ISO 控制字符**。实测(真实 {@code FormHttpMessageConverter} + 捕获线上字节):
+     *       {@code fileFieldName}、{@code formFields} 的**键**、{@code fileName} 三个值都会被原样
+     *       写进部件的 {@code Content-Disposition} 头,里面一个 CR / LF / tab 就能凭空多出一行头部
+     *       (线上一字不差长这样:{@code filename="a\r\nX-Evil: 1.jpg"} ⇒ {@code X-Evil: 1.jpg"}
+     *       成了部件头块里的独立一行)。</li>
+     *   <li>**null**。实测(同一套捕获装置,把守卫的 null 分支临时改回 {@code return} 跑出来的真实字节)
+     *       ——三种**都不是异常,而是静默改形状**,所以更要拒:
+     *       <ul>
+     *         <li>{@code fileName == null} ⇒ 部件变成
+     *             {@code Content-Disposition: form-data; name="img_file"}:<b>filename 段消失、
+     *             Content-Type 从 {@code image/jpeg} 退化成 {@code application/octet-stream}</b>
+     *             —— 文件部件**静默退化成一个普通表单字段**,上游多半只回一句笼统的「img_file 必填 / 类型不对」,
+     *             排查方向指向参数而不是客户端;</li>
+     *         <li>{@code fileFieldName == null} ⇒ {@code Content-Disposition: form-data; filename="a.jpg"}
+     *             —— <b>连 name 段都没有</b>(部件无名字,上游无法归属到任何字段);</li>
+     *         <li>{@code formFields} 的**键**为 null ⇒ {@code Content-Disposition: form-data}
+     *             (无名无 filename),且该部件**不进签名原文**(同一次实测里,签名与不含该键时逐字相同)
+     *             ⇒ 线上多出一个**既不具名、又不被签名覆盖**的部件。</li>
+     *       </ul>
+     *       {@code fileName} 尤其不是假想路径:它一般来自
+     *       {@code MultipartFile.getOriginalFilename()},那个 API 的契约就是可空的。</li>
+     * </ol>
+     * 而 {@code formFields} 的**值**出现在部件头块结束的那个空行**之后**
+     * ({@code Content-Length: N\r\n\r\n} 之后)⇒ 是部件**体**、不构成头部行,故**不**校验。
+     * ★ 判据是**位置**、不是「这些字节在不在报文里」:值里的 CR/LF **原样在报文里**
+     * ({@code wire.contains(value)} 恒为真),拿「字节在不在报文里」当判据会得出**相反**的结论
+     * ——「不在头部」与「不在报文里」是两个命题,前者为真推不出后者。
+     * <br>是**拒绝**不是清洗:清洗会让「客户端说发了 X、上游收到 Y」,而那道缝隙正是注入要利用的;
      * 拒绝也不改变任何**合法**文件名的线上形态。
      * <br>(被否决的替代方案:给转换器设 {@code multipartCharset} 确实能中和 CR/LF,
      * 但现有实现里它是 null 且全仓无人设置,设上会连带改掉**所有**文件名的编码形态
-     * —— 上游能否解 {@code filename*=} / encoded-word 文档没写 ⇒ 拿未知换已知,不划算。)
+     * —— 上游能否解 {@code filename*=} / encoded-word 文档没写 ⇒ 拿未知换已知,不划算。
+     * 对 null 它更是一点用都没有:null 不是编码问题。)
+     * <br>(**空串 / 全空白不在这里拒**:那是**上游侧**校验的活,T6 的 controller 收 ⇒ 400。
+     * 本层只拒「头部无法表示」的输入 —— 空串在 multipart 里是有表示的,就是空名字。)
      *
      * <p>★【已知残留:大文件没有写超时 —— 本方法**没有**解决它,也不许被写成已解决】
      * {@link LeshouyingHttpConfig} 那组超时防的是 F-1「对端握手后不回、把线程占住」,
@@ -160,16 +186,19 @@ public class LeshouyingClient {
      *
      * @param path          以 / 开头的路径
      * @param formFields    表单里的**字符串**字段(不含 inst_no / sign,本方法自动补);
-     *                      文件**不**走这里。**键**不得含 ISO 控制字符(**值**不限,值走部件体)
+     *                      文件**不**走这里。**键**不得为 null、不得含 ISO 控制字符
+     *                      (**值**两者都不限 —— 值走部件体,见上面第 3 段的位置判据)。
+     *                      整个 map 为 null 是允许的(等价于没有表单字段)
      * @param fileFieldName 文件部件的字段名(上游契约里是 {@code img_file});
-     *                      不得含 ISO 控制字符
+     *                      不得为 null、不得含 ISO 控制字符
      * @param fileName      文件部件的文件名(multipart 的 filename),不是表单字段;
-     *                      不得含 ISO 控制字符
+     *                      不得为 null、不得含 ISO 控制字符
      * @param fileBytes     文件内容,**不得为 null**:{@code new ByteArrayResource(null)} 在
      *                      {@code try} **之外**抛 {@link IllegalArgumentException},
      *                      **不会**被包成 {@link LeshouyingApiException} —— 调用方若只 catch
      *                      后者,这个错误会逃出它的错误通道(生产表现为 500)
-     * @throws IllegalArgumentException 上面三处含 ISO 控制字符,或 {@code fileBytes} 为 null
+     * @throws IllegalArgumentException 上面三个入口为 null 或含 ISO 控制字符,
+     *                                  或 {@code fileBytes} 为 null
      */
     public Map<String, Object> postMultipart(String path,
                                              Map<String, String> formFields,
@@ -189,17 +218,28 @@ public class LeshouyingClient {
 
     /**
      * 部件头注入守卫:只查三个**会进部件头**的入口(见 {@link #postMultipart} 的 javadoc 第 3 段)。
+     * 拒**两族**「头部无法表示」的输入,判据一致、异常类型一致(同一个
+     * {@link IllegalArgumentException}):
+     * <ol>
+     *   <li>**ISO 控制字符**({@link Character#isISOControl},覆盖 CR / LF / tab / NUL / DEL)
+     *       —— 拼进 {@code Content-Disposition} 会凭空多出一行头部;</li>
+     *   <li>**null** —— 部件头里没有「名字不存在」的表示法,实测是**静默改形状**而非报错
+     *       (三个入口的线上读数见 {@link #postMultipart} 的 javadoc 第 3 段)。</li>
+     * </ol>
      *
      * <p>为什么必须在**组实体之前**:这些值一旦交给 {@code FormHttpMessageConverter} 就会被原样
      * 写进 {@code Content-Disposition},一个 CR/LF 就是一行伪造的头部;在入口抛 ⇒ 报文根本没生成,
      * 也就无所谓「发出去一半」。
      *
      * <p>★ 异常消息**不带原值**:原值正是那串含控制字符的东西,把它拼进消息,调用方一记日志
-     * 就等于把 CWE-117(日志注入)从部件头搬进了日志 —— 同一颗雷换个地方。只报「哪个入口 + 哪个码点」。
+     * 就等于把 CWE-117(日志注入)从部件头搬进了日志 —— 同一颗雷换个地方。只报「哪个入口 + 哪个码点」;
+     * null 那一支只报「哪个入口 + 它是 null」(null 本身没有值可漏)。
      */
     private void requireHeaderSafe(String entry, String value) {
         if (value == null) {
-            return;
+            throw new IllegalArgumentException(
+                    entry + " 不得为 null:multipart 的部件头里没有「名字不存在」的表示法,"
+                            + "传 null 会让部件静默改形状(实测见 postMultipart 的 javadoc 第 3 段)");
         }
         for (int i = 0; i < value.length(); i++) {
             char c = value.charAt(i);

+ 60 - 4
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientMultipartTest.java

@@ -242,8 +242,11 @@ class LeshouyingClientMultipartTest {
      *   <li>{@code fileName} 含 CRLF ⇒ 线上 {@code filename="a\r\nX-Evil: 1.jpg"} —— 注入。</li>
      * </ul>
      * 三处的共同点是**值被拼进部件头**,于是一个 CR/LF 就凭空多出一行头部。
-     * 而 {@code formFields} 的**值**写在部件**体**里(由 {@code Content-Length} 界定),
-     * 不构成头部行 ⇒ **不校验**,见 {@link #postMultipart_formFieldValueGoesOnTheWireUnchanged}。
+     * 而 {@code formFields} 的**值**出现在部件头块结束的那个空行**之后**
+     * ({@code Content-Length: N\r\n\r\n} 之后)⇒ 是部件**体**、不构成头部行 ⇒ **不校验**,
+     * 见 {@link #postMultipart_formFieldValueGoesOnTheWireUnchanged}。
+     * ★ 判据是**位置**而不是「这些字节在不在报文里」:值里的 CR/LF **原样在报文里**,
+     * 用子串判据({@code wire.contains(value)})会得出**相反**的结论。
      *
      * <p>单 LF、tab、NUL 各自单列一条:CRLF 被拒**不代表**其余控制字符都被拒
      * (判据是 {@link Character#isISOControl},要的是整族,不是只堵 CRLF 两个字面量)。
@@ -279,18 +282,71 @@ class LeshouyingClientMultipartTest {
         assertRejectsHeaderBound("fileName", FILE_FIELD, "a\u0000b.jpg", Map.of("img_name", IMG_NAME));
     }
 
+    /**
+     * ★★ 拒绝:三个**会进部件头**的入口为 {@code null} ⇒ {@link IllegalArgumentException},
+     * 且**一个字节都不出海**({@code verifyNoInteractions(restTemplate)})。
+     *
+     * <p>null 与 CR/LF 属于**同一族**:multipart 的 {@code Content-Disposition} 里没有
+     * 「名字不存在」的表示法。判据不是猜的 —— 把守卫的 null 分支临时改回 {@code return}
+     * 跑出来的真实线上字节(探针见报告附录)三例**都不是异常,而是静默改形状**:
+     * <ul>
+     *   <li>{@code fileName == null} ⇒ {@code Content-Disposition: form-data; name="img_file"}
+     *       —— filename 段消失、Content-Type 从 {@code image/jpeg} 退化成
+     *       {@code application/octet-stream}:**文件部件变成一个普通表单字段**;</li>
+     *   <li>{@code fileFieldName == null} ⇒ {@code Content-Disposition: form-data; filename="a.jpg"}
+     *       —— **连 name 段都没有**;</li>
+     *   <li>表单**键**为 null ⇒ {@code Content-Disposition: form-data}(无名无 filename),
+     *       且该部件**不进签名原文**(签名与不含该键时逐字相同)⇒ 多出一个不具名、不被签名覆盖的部件。</li>
+     * </ul>
+     * {@code fileName} 尤其真实:它来自 {@code MultipartFile.getOriginalFilename()},
+     * 那个 API 的契约就是可空的。
+     */
+    @Test
+    void postMultipart_rejectsNullFileFieldName() {
+        assertRejectsNullHeaderBound("fileFieldName", null, FILE_NAME, Map.of("img_name", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsNullFileName() {
+        assertRejectsNullHeaderBound("fileName", FILE_FIELD, null, Map.of("img_name", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsNullFormFieldKey() {
+        // 用 LinkedHashMap 而不是 Map.of:Map.of 不接受 null 键,造不出这个输入。
+        Map<String, String> fields = new LinkedHashMap<>();
+        fields.put("img_name", IMG_NAME);
+        fields.put(null, "v");
+
+        assertRejectsNullHeaderBound("formFields 的键", FILE_FIELD, FILE_NAME, fields);
+    }
+
     /**
      * 三个入口共用的拒绝断言:抛 IAE、消息**点名是哪个入口**、且**没有发生任何出站**
      * (守卫在组实体之前 ⇒ mock 的 {@code restTemplate} 一次都没被碰过)。
+     * 返回异常本身,供 null 那一族再加断言(见 {@link #assertRejectsNullHeaderBound})。
      */
-    private void assertRejectsHeaderBound(String entryLabel, String fileFieldName,
-                                          String fileName, Map<String, String> formFields) {
+    private IllegalArgumentException assertRejectsHeaderBound(String entryLabel, String fileFieldName,
+                                                              String fileName, Map<String, String> formFields) {
         IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                 () -> client.postMultipart(UPLOAD_PATH, formFields, fileFieldName, fileName, FILE_BYTES));
 
         assertTrue(ex.getMessage().contains(entryLabel),
                 "异常消息要点名是哪个入口 —— 排障的人得知道去改哪一处,实际: " + ex.getMessage());
         verifyNoInteractions(restTemplate);
+        return ex;
+    }
+
+    /**
+     * null 那一族在共用断言之上再加一条:消息要**说明是 null**(而不是笼统的「参数非法」)。
+     * 与 CR/LF 一族同样**不带原值** —— null 本身没有值可漏,这里的判据只是可定位。
+     */
+    private void assertRejectsNullHeaderBound(String entryLabel, String fileFieldName,
+                                              String fileName, Map<String, String> formFields) {
+        IllegalArgumentException ex = assertRejectsHeaderBound(entryLabel, fileFieldName, fileName, formFields);
+
+        assertTrue(ex.getMessage().contains("null"),
+                "消息必须说明是 null 这一族,实际: " + ex.getMessage());
     }
 
     /**