Sfoglia il codice sorgente

fix(进件): multipart 部件头注入 - 三个头部绑定入口拒绝控制字符

新增前置条件守卫:postMultipart 入口对 fileFieldName、formFields 的每个键、fileName
三处拒绝 ISO 控制字符(Character.isISOControl,覆盖 CR / LF / tab / NUL / DEL),抛
IllegalArgumentException,且在任何出站之前(连实体都不构造)⇒ 一个字节都不出海。
是拒绝不是清洗:清洗会让「客户端说发了 X、上游收到 Y」,而那道缝隙正是注入要利用的。

判据是实测(真实 FormHttpMessageConverter + 捕获线上字节):三处都会被原样写进部件的
Content-Disposition,一个 CRLF 就在部件头块里造出一行新头部(filename="a\r\nX-Evil: 1.jpg"、
name="na\r\nX-Evil: 1me");而 formFields 的值写在部件体里(由 Content-Length 界定),
故不校验。上游文档没提这件事,javadoc 写明这是**我们**的前置条件、不是上游要求,
并记下被否决的替代方案(setMultipartCharset 确实能中和 CR/LF,但会连带改掉所有合法
文件名的编码形态,而上游能否解 encoded-word 未知 ⇒ 拿未知换已知)。
异常消息不带原值:原值正是含控制字符的那串,拼进消息等于把 CWE-117 从部件头搬进日志。

本轮一并补三条既有缺口:
- @param fileBytes 写明不得为 null(new ByteArrayResource(null) 在 try 之外抛 IAE,
  不会被包成 LeshouyingApiException ⇒ 调用方只 catch 后者时错误会逃出错误通道)。
- 钉住「表单字段的值原样上线」:从线上字节解出 img_name 的值再比对(探针值首尾带空格
  —— 普通名对 trim 不敏感,拿它测不出「有没有被规范化」)。此前 signedPayload 顺手
  trim 一下能骗过全部用例:签名用例是拿已发出的部件重算的(按构造自洽),
  wire 用例只断言部件名存在、不断言其值。
- 钉住 wireFormat 用例的装置假设:生产 bean 的转换器表 == RestTemplate 默认表。
  将来有人给它 setMessageConverters,其余用例全绿,而上传在运行期抛
  HttpMessageNotWritableException → 被兜底分支包成 NETWORK_ERROR,一个「上传坏了」
  的症状读起来像网络故障。

验证:LeshouyingClientMultipartTest 14 条全绿(原 5 + 新 9);全量 445 通过(436 + 9,
PaymentApplicationContextTest 实跑 7.25s ⇒ 库隧道在位)。
两向变异:M-A 去掉守卫 ⇒ 6 条拒绝用例红;M-B 守卫过宽(连非 ASCII 也拒)⇒ 正对照红
(这就是给锁验宽度:只验窄不验宽,一把过宽的锁能通过全部拒绝用例);M-C 无害(守卫从
入口挪进 postForRawMultipart 开头)⇒ 仍绿,且 diff 证实是纯搬移、变异体确实执行;
M-D signedPayload 对值 trim ⇒ 钉值用例红;M-E 给生产 bean 塞自定义转换器 ⇒ 装置假设用例红。
每轮还原均实测:客户端 sha256 回到 2aaf4b5b…,配置文件走 git checkout HEAD 后
git status --porcelain 对该文件为空。
alphaH 3 settimane fa
parent
commit
c3194b18ac

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

@@ -132,6 +132,21 @@ public class LeshouyingClient {
      * 上游按它自己那份参数集重算,必然对不上,而失败只会表现为一句笼统的「签名错误」。
      * 本方法不开任何旁路:签名仍走 {@link LeshouyingSigner},与另外两条腿同一套规则。
      *
+     * <p>【前置条件:三个**会进部件头**的入口拒绝 ISO 控制字符 —— 这是**我们**的约束,
+     * 上游文档(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」,而那道缝隙正是注入要利用的;
+     * 拒绝也不改变任何**合法**文件名的线上形态。
+     * <br>(被否决的替代方案:给转换器设 {@code multipartCharset} 确实能中和 CR/LF,
+     * 但现有实现里它是 null 且全仓无人设置,设上会连带改掉**所有**文件名的编码形态
+     * —— 上游能否解 {@code filename*=} / encoded-word 文档没写 ⇒ 拿未知换已知,不划算。)
+     *
      * <p>★【已知残留:大文件没有写超时 —— 本方法**没有**解决它,也不许被写成已解决】
      * {@link LeshouyingHttpConfig} 那组超时防的是 F-1「对端握手后不回、把线程占住」,
      * 但它挡不住**上传方向**的挂起:{@code SimpleClientHttpRequestFactory} 只设了
@@ -145,20 +160,57 @@ public class LeshouyingClient {
      *
      * @param path          以 / 开头的路径
      * @param formFields    表单里的**字符串**字段(不含 inst_no / sign,本方法自动补);
-     *                      文件**不**走这里
-     * @param fileFieldName 文件部件的字段名(上游契约里是 {@code img_file})
-     * @param fileName      文件部件的文件名(multipart 的 filename),不是表单字段
-     * @param fileBytes     文件内容
+     *                      文件**不**走这里。**键**不得含 ISO 控制字符(**值**不限,值走部件体)
+     * @param fileFieldName 文件部件的字段名(上游契约里是 {@code img_file});
+     *                      不得含 ISO 控制字符
+     * @param fileName      文件部件的文件名(multipart 的 filename),不是表单字段;
+     *                      不得含 ISO 控制字符
+     * @param fileBytes     文件内容,**不得为 null**:{@code new ByteArrayResource(null)} 在
+     *                      {@code try} **之外**抛 {@link IllegalArgumentException},
+     *                      **不会**被包成 {@link LeshouyingApiException} —— 调用方若只 catch
+     *                      后者,这个错误会逃出它的错误通道(生产表现为 500)
+     * @throws IllegalArgumentException 上面三处含 ISO 控制字符,或 {@code fileBytes} 为 null
      */
     public Map<String, Object> postMultipart(String path,
                                              Map<String, String> formFields,
                                              String fileFieldName,
                                              String fileName,
                                              byte[] fileBytes) {
+        requireHeaderSafe("fileFieldName", fileFieldName);
+        requireHeaderSafe("fileName", fileName);
+        if (formFields != null) {
+            for (String name : formFields.keySet()) {
+                requireHeaderSafe("formFields 的键", name);
+            }
+        }
         return parser.parseApi(
                 postForRawMultipart(path, formFields, fileFieldName, fileName, fileBytes));
     }
 
+    /**
+     * 部件头注入守卫:只查三个**会进部件头**的入口(见 {@link #postMultipart} 的 javadoc 第 3 段)。
+     *
+     * <p>为什么必须在**组实体之前**:这些值一旦交给 {@code FormHttpMessageConverter} 就会被原样
+     * 写进 {@code Content-Disposition},一个 CR/LF 就是一行伪造的头部;在入口抛 ⇒ 报文根本没生成,
+     * 也就无所谓「发出去一半」。
+     *
+     * <p>★ 异常消息**不带原值**:原值正是那串含控制字符的东西,把它拼进消息,调用方一记日志
+     * 就等于把 CWE-117(日志注入)从部件头搬进了日志 —— 同一颗雷换个地方。只报「哪个入口 + 哪个码点」。
+     */
+    private void requireHeaderSafe(String entry, String value) {
+        if (value == null) {
+            return;
+        }
+        for (int i = 0; i < value.length(); i++) {
+            char c = value.charAt(i);
+            if (Character.isISOControl(c)) {
+                throw new IllegalArgumentException(String.format(
+                        "%s 不得含 ISO 控制字符(会注入 multipart 部件头):位置 %d 上是 U+%04X",
+                        entry, i, (int) c));
+            }
+        }
+    }
+
     /**
      * multipart 腿的请求侧拼装:**只**对字符串字段补 inst_no / sign,文件另作一个带文件名的文件部件。
      *

+ 180 - 12
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientMultipartTest.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.client;
 
+import com.payment.platform.module.payment.leshouying.config.LeshouyingHttpConfig;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -20,17 +21,21 @@ import org.springframework.web.client.RestTemplate;
 
 import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 /**
@@ -71,6 +76,18 @@ class LeshouyingClientMultipartTest {
     private static final String FILE_MARKER = "FAKE-JPEG-BODY-MUST-NOT-BE-SIGNED";
     private static final byte[] FILE_BYTES = FILE_MARKER.getBytes(StandardCharsets.UTF_8);
 
+    /**
+     * 正对照用的**合法**文件名:中文 + 空格。过宽的守卫(拒非 ASCII / 拒空格)会被它抓到。
+     */
+    private static final String LEGAL_CN_NAME = "工资表 2026-09.jpg";
+
+    /**
+     * ★ 值原样上线的探针值:**首尾各带两个空格**。
+     * 普通名(如 {@code business-licence.jpg})对 trim 不敏感 —— 拿它测「值有没有被规范化」
+     * 是测不出来的,一个顺手 trim 的实现照样绿。要让这件事**可观测**,值本身必须对规范化敏感。
+     */
+    private static final String PADDED_IMG_NAME = "  2026-09-22 upload.jpg  ";
+
     private static final String OK_BODY =
             "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"img_url\":\"http://x/y.jpg\"}";
 
@@ -203,6 +220,148 @@ class LeshouyingClientMultipartTest {
      */
     @Test
     void postMultipart_wireFormat_filePartCarriesFilename() {
+        String wire = captureWire(Map.of("img_name", IMG_NAME), FILE_NAME);
+
+        assertTrue(wire.contains("Content-Disposition: form-data; name=\"" + FILE_FIELD
+                        + "\"; filename=\"" + FILE_NAME + "\""),
+                "文件部件的 Content-Disposition 必须带 filename,实际报文:\n" + wire);
+        assertTrue(wire.contains("name=\"img_name\""), "字符串字段必须在报文里,实际报文:\n" + wire);
+        assertFalse(wire.contains("name=\"img_name\"; filename="),
+                "字符串字段被写成了文件部件(不该有 filename 段),实际报文:\n" + wire);
+        assertTrue(wire.contains(FILE_MARKER), "文件内容必须真的写进报文,实际报文:\n" + wire);
+    }
+
+    /**
+     * ★★ 拒绝:三个**会进部件头**的入口含 ISO 控制字符 ⇒ {@link IllegalArgumentException},
+     * 且**一个字节都不出海**({@code verifyNoInteractions(restTemplate)})。
+     *
+     * <p>**判据是实测的**(真实 {@code FormHttpMessageConverter},探针见报告附录):
+     * <ul>
+     *   <li>{@code fileFieldName} 含 CRLF ⇒ 线上 {@code name="img\r\nX-Evil: 1"} —— 注入;</li>
+     *   <li>{@code formFields} 的**键**含 CRLF ⇒ 线上 {@code name="na\r\nX-Evil: 1me"} —— 注入;</li>
+     *   <li>{@code fileName} 含 CRLF ⇒ 线上 {@code filename="a\r\nX-Evil: 1.jpg"} —— 注入。</li>
+     * </ul>
+     * 三处的共同点是**值被拼进部件头**,于是一个 CR/LF 就凭空多出一行头部。
+     * 而 {@code formFields} 的**值**写在部件**体**里(由 {@code Content-Length} 界定),
+     * 不构成头部行 ⇒ **不校验**,见 {@link #postMultipart_formFieldValueGoesOnTheWireUnchanged}。
+     *
+     * <p>单 LF、tab、NUL 各自单列一条:CRLF 被拒**不代表**其余控制字符都被拒
+     * (判据是 {@link Character#isISOControl},要的是整族,不是只堵 CRLF 两个字面量)。
+     */
+    @Test
+    void postMultipart_rejectsCrLfInFileFieldName() {
+        assertRejectsHeaderBound("fileFieldName", "img\r\n_file", FILE_NAME, Map.of("img_name", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsCrLfInFormFieldKey() {
+        assertRejectsHeaderBound("formFields 的键", FILE_FIELD, FILE_NAME,
+                Map.of("na\r\nme", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsCrLfInFileName() {
+        assertRejectsHeaderBound("fileName", FILE_FIELD, "a\r\nb.jpg", Map.of("img_name", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsLoneLfInFileName() {
+        assertRejectsHeaderBound("fileName", FILE_FIELD, "a\nb.jpg", Map.of("img_name", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsTabInFileName() {
+        assertRejectsHeaderBound("fileName", FILE_FIELD, "a\tb.jpg", Map.of("img_name", IMG_NAME));
+    }
+
+    @Test
+    void postMultipart_rejectsNulInFileName() {
+        assertRejectsHeaderBound("fileName", FILE_FIELD, "a\u0000b.jpg", Map.of("img_name", IMG_NAME));
+    }
+
+    /**
+     * 三个入口共用的拒绝断言:抛 IAE、消息**点名是哪个入口**、且**没有发生任何出站**
+     * (守卫在组实体之前 ⇒ mock 的 {@code restTemplate} 一次都没被碰过)。
+     */
+    private void 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);
+    }
+
+    /**
+     * ★ 正对照:**合法**文件名必须通过 —— 中文 + 空格({@link #LEGAL_CN_NAME})。
+     *
+     * <p>只测「坏名字被拒」是**发现不了一把过宽的锁**的:一个「拒非 ASCII」或「拒空格」的实现
+     * 能让上面六条拒绝用例**全绿**,而真实上传会被全拒。这条是给那把锁**验宽度**的
+     * (变异 M-B 打的就是它)。
+     */
+    @Test
+    void postMultipart_acceptsLegalChineseNameWithSpace() {
+        stubOk(OK_BODY);
+
+        Map<String, Object> resp = client.postMultipart(UPLOAD_PATH, Map.of("img_name", LEGAL_CN_NAME),
+                FILE_FIELD, LEGAL_CN_NAME, FILE_BYTES);
+
+        assertEquals("SUCCESS", resp.get("result_code"));
+        verify(restTemplate).postForEntity(
+                eq(BASE_URL + UPLOAD_PATH), any(HttpEntity.class), eq(String.class));
+    }
+
+    /**
+     * ★ 钉住「表单字段的值**原样**上线」。
+     *
+     * <p>能骗过全部既有用例的实现:{@code signedPayload} 顺手对值做 trim / 规范化,
+     * 或 multipart 腿拿**另一份** map 去拼 body —— 上游收到的名字与调用方给的不一致。
+     * 既有用例接不住:签名用例的 verify 是拿**已发出**的部件重算的(按构造自洽),
+     * wire 用例只断言 {@code name="img_name"} **存在**、不断言它的值。
+     *
+     * <p>值**首尾带空格**是刻意的:普通名({@code business-licence.jpg})对 trim 不敏感,
+     * 拿它测「有没有被规范化」是测不出来的 —— 值必须本身就对规范化敏感(见 {@link #PADDED_IMG_NAME})。
+     */
+    @Test
+    void postMultipart_formFieldValueGoesOnTheWireUnchanged() {
+        String wire = captureWire(Map.of("img_name", PADDED_IMG_NAME), FILE_NAME);
+
+        assertEquals(PADDED_IMG_NAME, partValueFromWire(wire, "img_name"),
+                "表单字段的值必须原样上线(不许 trim / 规范化),实际报文:\n" + wire);
+    }
+
+    /**
+     * ★ 钉住上面那条 wireFormat 用例所依赖的**装置假设**:「生产的 {@code lsyRestTemplate}
+     * 用的是 {@code RestTemplate} 默认转换器表,那条用例与生产只差一个请求工厂」。
+     *
+     * <p>今天成立({@code LeshouyingHttpConfig.lsyRestTemplate()} 实测就是
+     * {@code new RestTemplate(factory)}),但此前**无人钉住**。具体会被这条接住的失败场景:
+     * 将来有人给该 bean 加 {@code setMessageConverters(...)},本类其余用例**全绿**,
+     * 而上传在运行期抛 {@code HttpMessageNotWritableException} —— 它会被
+     * {@link LeshouyingApiException} 的兜底分支包成 {@code NETWORK_ERROR},
+     * 于是一个「上传坏了」的症状读起来像网络故障,排查方向直接跑偏。
+     */
+    @Test
+    void lsyRestTemplateBean_usesDefaultConverterTable() {
+        List<Class<?>> defaults = converterTypes(new RestTemplate());
+
+        assertEquals(defaults, converterTypes(new LeshouyingHttpConfig().lsyRestTemplate()),
+                "生产 bean 的转换器表与 RestTemplate 默认表不一致 ⇒ 本类 wireFormat 用例的装置"
+                        + "不再等同于生产(上传会变成 NETWORK_ERROR,见本方法 javadoc)");
+    }
+
+    private List<Class<?>> converterTypes(RestTemplate template) {
+        return template.getMessageConverters().stream()
+                .map(Object::getClass)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 用**真实** {@code RestTemplate}(默认转换器表,与生产的 {@code lsyRestTemplate} 只差请求工厂)
+     * 把请求真正序列化一遍,返回线上字节。装置的正身在此,两个字节级用例共用。
+     */
+    private String captureWire(Map<String, String> formFields, String fileName) {
         AtomicReference<MockClientHttpRequest> captured = new AtomicReference<>();
         ClientHttpRequestFactory capturingFactory = (uri, method) -> {
             MockClientHttpRequest req = new MockClientHttpRequest(method, uri);
@@ -211,21 +370,30 @@ class LeshouyingClientMultipartTest {
             captured.set(req);
             return req;
         };
-        LeshouyingClient wireClient = new LeshouyingClient(new RestTemplate(capturingFactory),
-                new LeshouyingSigner(), new LeshouyingResponseParser(), props);
 
-        wireClient.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
-                FILE_FIELD, FILE_NAME, FILE_BYTES);
+        new LeshouyingClient(new RestTemplate(capturingFactory),
+                new LeshouyingSigner(), new LeshouyingResponseParser(), props)
+                .postMultipart(UPLOAD_PATH, formFields, FILE_FIELD, fileName, FILE_BYTES);
 
-        String wire = captured.get().getBodyAsString(StandardCharsets.UTF_8);
+        return captured.get().getBodyAsString(StandardCharsets.UTF_8);
+    }
 
-        assertTrue(wire.contains("Content-Disposition: form-data; name=\"" + FILE_FIELD
-                        + "\"; filename=\"" + FILE_NAME + "\""),
-                "文件部件的 Content-Disposition 必须带 filename,实际报文:\n" + wire);
-        assertTrue(wire.contains("name=\"img_name\""), "字符串字段必须在报文里,实际报文:\n" + wire);
-        assertFalse(wire.contains("name=\"img_name\"; filename="),
-                "字符串字段被写成了文件部件(不该有 filename 段),实际报文:\n" + wire);
-        assertTrue(wire.contains(FILE_MARKER), "文件内容必须真的写进报文,实际报文:\n" + wire);
+    /**
+     * 从线上字节里解出某个字符串部件的**值**:部件头块以空行结束,值写到下一个
+     * {@code \r\n--boundary} 之前。解不出返回 null(断言会带着报文原文失败,不会静默)。
+     */
+    private String partValueFromWire(String wire, String partName) {
+        int at = wire.indexOf("name=\"" + partName + "\"");
+        if (at < 0) {
+            return null;
+        }
+        int bodyStart = wire.indexOf("\r\n\r\n", at);
+        if (bodyStart < 0) {
+            return null;
+        }
+        bodyStart += 4;
+        int bodyEnd = wire.indexOf("\r\n--", bodyStart);
+        return bodyEnd < 0 ? null : wire.substring(bodyStart, bodyEnd);
     }
 
     private void stubOk(String body) {