138 lines
8.6 KiB
Markdown
138 lines
8.6 KiB
Markdown
# 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)需用户确认后启动。按计划每阶段出报告后停止。
|