|
|
@@ -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);
|