Procházet zdrojové kódy

docs(进件): T6 补控制字符校验 + 正对照 - 部件头注入实测三个入口

探针实测 FormHttpMessageConverter 把 fileName 原样写进 Content-Disposition,
CRLF 在部件头块里造出新头行;单 LF 同样注入。另测得 fileFieldName 与
formFields 的键也是头部绑定入口(实施者只测了 fileName)。
客户端那层由 Task 2 fix round 抛 IAE 兜住,T6 仍须自己校验成 BusinessException(400)
——两层都要,异常类型不同、理由不同。附正对照用例防过宽的锁。
alphaH před 3 týdny
rodič
revize
15413cd351
1 změnil soubory, kde provedl 16 přidání a 0 odebrání
  1. 16 0
      .claude/plan/2026-09-22-lsy-onboarding-A.md

+ 16 - 0
.claude/plan/2026-09-22-lsy-onboarding-A.md

@@ -438,6 +438,8 @@ Run: `... -Dtest=LsyOnboardingServiceTest`
 | `uploadImage_emptyBytes_rejected` | 传 0 字节 ⇒ `BusinessException(400, ...)` |
 | `uploadImage_disallowedExtension_rejected` | `evil.exe` / `x.jpg.php` / `x`(无后缀)⇒ 400。`a.JPG`(大写)⇒ **通过**(扩展名匹配不区分大小写,这条钉住那个 `toLowerCase`) |
 | `uploadImage_fileNameWithPathSeparator_rejected` | `../../etc/passwd.jpg` / `a\\b.jpg` ⇒ 400 |
+| `uploadImage_controlCharInFileName_rejected` | `a\r\nX-Evil: 1.jpg` / `a\nb.jpg` / `a\u0000b.jpg` / `a\tb.jpg` ⇒ 400,且**一次上游调用都没发出**(`verifyNoInteractions(client)`)。**★ 钉的是 2026-09-22 实测出的部件头注入**(见实现要点)|
+| `uploadImage_ordinaryFilename_accepted` | **正对照**:`工资表 2026-09.jpg`(中文 + 空格)⇒ **通过**。没有这条,一把过宽的控制字符锁会静默拒掉所有真实上传,而上面那条照样绿 |
 | `uploadImage_fileNameLengthCapped` | 超过上游 `img_name` 能接受的长度(文档**没写**上限 ⇒ **不设**上游长度上限),只断言我们**不**擅自截断:长文件名原样送出去。**★ 这条是「不做」的钉子**,防止后来者顺手加个 `substring(0,50)` |
 
 - [ ] **Step 2: 跑测试确认红**
@@ -450,6 +452,18 @@ Run: `... -Dtest=LsyOnboardingServiceTest`
 - **不要**把它加进 `validate()`:`validate()` 只管「缺了就静默失败」的项(见它自己的 javadoc),一个带合理默认值的上限不属于那类。`LeshouyingPropertiesTest` 补一条断言它不在必填列表里(**正对照**:确证 `validate()` 在有默认值时不炸)。
 - 扩展名白名单:`jpg` / `jpeg` / `png` / `bmp`,取自文档示例出现的形态;**常量 + javadoc**说明依据是「上游字段名全是『照片』语义 + 文档示例是 `.jpg`」,这是我们收窄的,不是上游列举的。
 - 文件名含 `/`、`\`、`..` ⇒ 400(**不是**「清洗后照发」:清洗会让日志里的名字与实际发出去的名字不一致,排障时会骗人)。
+- **★ 文件名含 ISO 控制字符 ⇒ 400**(`Character.isISOControl`:CR / LF / tab / NUL / DEL)。
+  依据是 **2026-09-22 的实测**,不是推测:multipart 部件头是拼在**报文体**里的,
+  `FormHttpMessageConverter` 把 `fileName` 原样写进 `Content-Disposition` ⇒ 名里的 CRLF
+  会在部件头块里**造出一个新的头行**(探针读数 `filename="a\r\nX-Evil: 1.jpg"`,真 CRLF;
+  单 LF 同样注入)。实测**三个**头部绑定入口都会注入:`fileName`、`fileFieldName`、`formFields` 的键。
+  - **客户端那层已经替你兜住了**(Task 2 的 fix round 在 `postMultipart` 入口对这三处
+    抛 `IllegalArgumentException`)—— 但**你仍必须自己校验一次**:客户端抛的是
+    **程序员错误**语义,直接漏出去就是 500;而这里的文件名来自**上传方**,是**用户输入**,
+    必须变成 `BusinessException(400, 中文名)`。**两层都要,异常类型不同,理由不同。**
+  - **不要**把这条写成「上游要求」—— 上游文档没提这个,这是**我们**的护栏(同 `uploadMaxBytes` 的写法)。
+- **正对照(必须有)**:普通文件名含中文与空格(如 `工资表 2026-09.jpg`)**必须通过**。
+  只测「坏名字被拒」发现不了一把过宽的锁 —— 那会把真实上传全拒掉。
 - 复用 Task 2 的 `postMultipart`:`path = "/api/info/upload/img"`、`fileFieldName = "img_file"`。
 - `img_name` 这个**字符串部件**与文件部件上的 filename 都从**同一个形参** `fileName` 派生。
   依据:文档把 `img_name` 列为**必填字符串部件**、示例值 `8510_pic_idcard.jpg` 与文件名同形。
@@ -468,6 +482,8 @@ Run: `... -Dtest=LsyOnboardingServiceTest`
 | M-4:把路径分隔符改成「清洗后照发」 | `uploadImage_fileNameWithPathSeparator_rejected` **红** |
 | M-5:给文件名加 `substring(0, 50)` | `uploadImage_fileNameLengthCapped` **红** |
 | M-6(无害):把白名单常量从 `Set.of` 换成 `List.of` | **仍绿** |
+| M-7:去掉控制字符校验 | `uploadImage_controlCharInFileName_rejected` **红** |
+| M-8:把控制字符校验写成「过宽」(连中文/空格也拒) | `uploadImage_ordinaryFilename_accepted` **红**(**★ 这条变异是给锁验宽度的**:只验窄不验宽,过宽的锁照样通过全部拒绝用例)|
 
 - [ ] **Step 6: Commit** —— `feat(进件): 图片上传 - 本地大小与类型护栏在出站之前`