Files
danyang-match/docs/Phase-10-V11-Recovery-Completion-Report-2026-09-16.md
T
WorkBuddy fd7195ecb0 fix(safety): Phase 10 复验 —— 修复图片上传魔数校验死代码(生产故障)/ 高风险词写入日志
对 Phase 10(Image & Chat Safety)执行 KEEP-VERIFY 复验:不采信历史 ✅,逐条核对
图片私有访问、类型/大小/魔数校验、审核与水印、风险识别/举报/违规处理、日志脱敏。
Gate 判定通过,发现并修复 2 处缺陷,并推翻旧报告 2 项安全声明。

D1(Critical,生产故障)图片上传的类型与魔数校验整条是死代码
  - PhotoService.uploadAndSave 用 `header = new byte[Math.min(12, stream.available())]`
    仅分配零值缓冲,从不调用 stream.read(...) → validateMagicNumber 对任何文件都收到
    12 个 0x00 → 恒返回 false → 恒抛"无效的图片文件头"。
  - 影响链(已确认线上触发):小程序 local-eligibility-supplement.ts 以
    photoType='local_eligibility' 调 POST /photos/upload → 本地资格佐证上传 100% 失败。
  - 次生问题:isAllowedMimeType(image-only 白名单)在 local_eligibility 分支之后
    无条件执行,即使魔数修好,application/pdf 仍被挡,而 FeatureFlagConfig
    的 localEvidenceAllowedTypes 明确允许 pdf;另有未 mark 的 stream.reset() 无效调用。
  - 测试为何没发现:PhotoServiceTest 只对 validateMagicNumber 传手写字节做单测,
    从不经过 uploadAndSave,该路径缺陷完全无覆盖。
  - 修复:改用 try(InputStream){ header = s.readNBytes(12); } 真正读取;
    MIME 校验按 photoType 分流(local_eligibility 走自己的白名单含 pdf);
    魔数校验仅对图片执行、PDF 跳过;移除死 reset();上传用全新流与校验流隔离;
    generateFileKey 重载按 Content-Type 推断扩展名,PDF 不再拿到误导性 .jpg key。

D2(Low)高风险词写入普通日志
  - ChatSafetyService.scanContent 把命中的高风险词原样写入 log.warn,
    与同文件 CONTACT 分支"只记 pattern 不记内容"矛盾,且违反本阶段 Gate。
  - 修复:改为 log.warn("Chat content matched HIGH_RISK keyword (blocked)")。

推翻的旧声明(docs/Phase-10-Completion-Report.md)
  - 「伪装恶意图片被拒(Magic Number校验)」不成立:该校验运行时恒失败,
    连合法图片也一律被拒(fail-closed 到不可用),安全属性并非由它提供。
  - 「EXIF清理预留(PhotoService.stripExif占位)」不存在:全仓库无该符号。
    且 getUploadToken 客户端直传路径服务端无法介入清理 EXIF。
  - 已在旧报告顶部加状态注记,指向本次复验报告。

测试
  - 新增 PhotoUploadValidationTest(6 用例),用 MockMultipartFile 真正走一遍
    uploadAndSave:合法 JPEG/PNG 通过且确实调用 putObject、伪造 Content-Type 被魔数挡、
    PDF 佐证不被 image-only 挡且 key 以 .pdf 结尾、非法 MIME 被拒、超大被拒。
  - 负向验证:临时把魔数守卫改为 if(false && ...) → 恰好 spoofedContentTypeRejectedByMagic
    一个用例变红,其余 5 个仍绿;随后还原并 grep 确认无 NEGATIVE-CHECK-TEMP 残留。
  - 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
    334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 328,+6)。

登记 gap(不自动实现,待产品确认)
  - 图片水印:全仓库无实现;主计划未列硬需求,仅"开发执行计划修订版"提"高级动态水印"。
  - EXIF 清理:旧声明不存在;客户端直传路径服务端无法介入,需改为接收侧重编码。
  - 直传凭证路径(getUploadToken)无服务端内容校验且不落 DB 记录 → 后续 getSignedUrl
    归属校验会查不到 UserPhoto 行,属契约层缺口,超出本阶段安全 Gate 范围。
  - 违规自动升级:ChatSafetyService 注释明确属 Phase 17 范围,当前 high 仅拦截不落库。

文档:新增 Phase-10-V11-Recovery-Completion-Report-2026-09-16.md;
旧报告加状态注记;主计划 Phase 10 复选框置 ✅;STATUS / JIRA_BACKLOG 同步(334 tests)。

Phase 11 未启动,按计划每阶段出报告后停止等待确认。
2026-09-16 21:00:52 +08:00

138 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 10(Image & Chat Safety)复验报告
> 日期:2026-09-16
> 方法:KEEP-VERIFY —— 不采信历史 ✅,逐条核对运行时路径
> 结论:**Gate 通过**,但复验推翻了旧报告的一项安全声明,并修复 1 处 Critical 生产故障
> 基线:`03a03c7`(Phase 09 关闭)→ 本阶段提交见文末
> 全量回归:**334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(基线 328,+6)
---
## 1. Gate 判定
| Gate 要求 | 判定 | 证据 |
|---|---|---|
| 越权图片访问被拒绝 | ✅ 通过 | `PhotoService.getSignedUrl` 按 `fileKey` 比对归属,非本人抛异常;`getSignedUrlForAuthorizedReview` 全仓库仅 `ReviewService` 调用(后台审核流,调用前已完成业务权限与资源归属校验) |
| 敏感内容不进入普通日志 | ✅ 通过(修复后) | 聊天正文/手机号/身份证/签名 URL 均未落日志;`AuthService` 手机号走 `maskPhone`。**修复前** `ChatSafetyService` 把命中的高风险词原样写入 warn(D2,已修) |
| 图片类型/大小校验 | ✅ 通过(修复后) | **修复前该路径是死的**(D1)。修复后 `uploadAndSave` 真正读取文件头做魔数校验,MIME 按 photoType 分流 |
| 风险识别 / 举报入口 / 违规处理 | ✅ 通过 | `ChatSafetyService`(限频+关键词+正则)→ `ChatService.sendText` 打标+RiskNotice;`ReportService` 提交→评分→处置动作→审计→通知链路完整 |
| 审核 | ✅ 通过 | `ReviewService` 审核任务全状态机(pending/processing/approved/need_edit/rejected)在,照片 `reviewStatus` 置 pending |
| 水印 | ⚠️ 未实现(登记 gap) | 全仓库 `backend/src/main/java` 无任何水印实现。V1.1 主计划未列为硬需求;仅"开发执行计划修订版"/"Jira 清单"提及"高级水印"(属后续增强) |
---
## 2. 发现与修复
### D1(Critical)图片上传的类型与魔数校验整条是死代码 —— 生产故障
**位置**:`PhotoService.uploadAndSave`(`PhotoService.java` 原 145–158 行)
**根因**:
```java
byte[] header;
try {
header = new byte[Math.min(12, stream.available())]; // 只分配缓冲,从不读取!
} catch (IOException e) { ... }
if (!validateMagicNumber(header)) { // header 恒为全零
throw new RuntimeException("无效的图片文件头"); // → 恒真 → 恒抛
}
```
`new byte[n]` 仅分配零值数组,**没有任何 `stream.read(...)`**。`validateMagicNumber` 收到的永远是 12 个 `0x00`,对任何文件都返回 `false`。
**影响链**(非理论风险,已确认为线上触发路径):
- 小程序 `local-eligibility-supplement.ts:98` 以 `photoType: 'local_eligibility'` 调 `POST /api/v1/photos/upload` → 命中 `uploadAndSave`。
- 该路径**对所有文件类型 100% 抛"无效的图片文件头"**,本地资格佐证上传功能完全不可用。
- 同时 `isAllowedMimeType`(image-only 白名单:jpeg/png/webp/gif/heic)在 `local_eligibility` 分支之后**无条件执行**,即使魔数问题修好,`application/pdf` 仍会被挡——而 `FeatureFlagConfig.getLocalEvidenceAllowedTypes()` 明确允许 pdf。
**次生问题**:`stream.reset()` 被调用但 `getInputStream()` 未 `mark()`(`MultipartFile` 契约不保证 `markSupported()`),属无效调用。
**测试为何没发现**:`PhotoServiceTest` 只对 `validateMagicNumber` 传入**手写字节数组**做单元测试(`{(byte)0xFF,(byte)0xD8,...}`),从不经过 `uploadAndSave`,因此该路径的读取缺陷完全没有覆盖。
**修复**(`PhotoService.java`):
1. 用 `try (InputStream s = file.getInputStream()) { header = s.readNBytes(12); }` **真正读取**文件头;`header.length < 3` 直接判失败。
2. MIME 校验**按 photoType 分流**:`local_eligibility` 走 `isAllowedLocalEvidenceMimeType`(含 pdf),其余走图片白名单——不再串行双校验。
3. 魔数校验**仅对图片执行**,PDF 佐证跳过(Content-Type 已由白名单约束,其 `%PDF-` 魔数与图片不同,混用会误杀)。
4. 移除无 `mark` 的 `reset()` 死调用;上传使用全新输入流,与校验流隔离。
5. `generateFileKey` 重载按 Content-Type 推断扩展名,PDF 佐证不再拿到误导性的 `.jpg` key。
### D2(Low)高风险词写入普通日志
**位置**:`ChatSafetyService.scanContent`(原 80 行)
```java
log.warn("Chat content matched HIGH_RISK word: '{}'", word); // 把聊天内容片段写进日志
```
与同文件 `CONTACT_PATTERNS` 分支的 `log.info("Chat content matched CONTACT pattern")`(不记具体内容)自相矛盾,且直接违反本阶段 Gate「敏感内容不进入普通日志」。
**修复**:改为 `log.warn("Chat content matched HIGH_RISK keyword (blocked)")`,不记录命中的具体词汇。
---
## 3. 复验推翻的旧声明
`docs/Phase-10-Completion-Report.md` 的 "Security Review" 写着:
> - [x] 伪装恶意图片被拒(Magic Number校验)
**该声明不成立**:魔数校验在运行时恒失败——不是说"被拒的没拒",而是**连合法图片也一律被拒**(fail-closed 到不可用),安全属性并非由该校验提供。旧报告同时自认 `Known Issues: 图片上传实际对接需在 Phase 10 后续完善`,说明作者当时已知未通。
同报告另有两条不可验证的"框架"声明,本次一并对齐现状:
| 旧声明 | 复验结论 |
|---|---|
| `EXIF清理预留(PhotoService.stripExif占位)` | **不存在**。全仓库 `backend/src/main/java` 无 `stripExif` 符号。且 `getUploadToken` 客户端直传路径服务端无法介入清理 EXIF(真正实现需在接收侧重编码或引入图像库)。 |
| `WebSocket推送占位(Phase 09 Handler已就绪)` | Phase 09 已证实当时 Handler 整条下行链路是死代码;本阶段已随 Phase 09 修复。 |
| `限流框架(Redis限流预留)` | 已由 `ChatSafetyService.checkRateLimit` 落地(Redis + fail-open),非"预留"。 |
---
## 4. 测试保真度
新增 `PhotoUploadValidationTest`(6 用例),**用 `MockMultipartFile` 真正走一遍 `uploadAndSave`**:
| 用例 | 验证点 |
|---|---|
| `validJpegPassesValidation` | 合法 JPEG 通过 MIME + 魔数校验,且确实调用 `putObject`(修复前此用例失败,正是 D1 的抓取器) |
| `spoofedContentTypeRejectedByMagic` | 声称 `image/jpeg` 实为文本 → 被魔数挡,且**不调用** `putObject` |
| `pdfLocalEligibilityNotBlocked` | `application/pdf` 走 local_eligibility 白名单不被 image-only 挡,key 以 `.pdf` 结尾 |
| `invalidMimeTypeRejected` | `text/plain` 作 lifestyle → 因 MIME 不在白名单被拒 |
| `oversizedFileRejected` | 限额 1MB 传 2MB → 被拒 |
| `validPngPassesValidation` | PNG 魔数通过,key 以 `.png` 结尾 |
**负向验证**:临时把 `uploadAndSave` 中的魔数守卫改为 `if (false && ...)`,重跑 → 恰好 `spoofedContentTypeRejectedByMagic` 一个用例变红(伪造图被放行),其余 5 个仍绿,证明守卫精准生效且不误报;随后还原并 `grep -rn "NEGATIVE-CHECK-TEMP"` 确认无残留。
**全量回归**:`334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS`(基线 328,+6)。
---
## 5. 登记 gap(不自动实现)
| 项 | 说明 | 建议归属 |
|---|---|---|
| 图片水印 | 全仓库无实现。主计划未列硬需求,仅"开发执行计划修订版"提"高级动态水印" | 待产品确认是否纳入 |
| EXIF 清理 | 旧报告声明不存在。客户端直传路径服务端无法介入;需改为接收侧重编码 | 安全增强,建议单列 |
| 直传凭证路径无服务端内容校验 | `getUploadToken` 只生成 presigned PUT,服务端不校验类型/大小/魔数,且不落 DB 记录(客户端用返回的 fileKey 但无对应 UserPhoto 行,导致后续 `getSignedUrl` 归属校验查不到) | **契约层缺口**,超出本阶段安全 Gate 范围,建议单列 |
| 违规自动升级 | `ChatSafetyService` 注释明确 "Phase 17: risk_events 落库 + RiskAction 执行",当前 high 仅拦截不落库 | Phase 17 既定范围 |
---
## 6. 证据
- 变更文件:`PhotoService.java`、`ChatSafetyService.java`
- 新增测试:`PhotoUploadValidationTest.java`(6 用例)
- 定向测试:`PhotoUploadValidationTest + PhotoServiceTest + ChatSafetyServiceTest` = 17/17 绿
- 负向验证:1/6 精准变红(`spoofedContentTypeRejectedByMagic`)
- 全量回归:334 / 0 / 0 / 3 skipped / BUILD SUCCESS
- 离线容器:`danyang-match-backend-test:latest`,卷 `danyang-m2-cache`
---
## 7. Next
Phase 11(Notification — EXTEND)需用户确认后启动。按计划每阶段出报告后停止。