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

8.6 KiB
Raw Blame History

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 行)

根因:

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 行)

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" 写着:

  • 伪装恶意图片被拒(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)需用户确认后启动。按计划每阶段出报告后停止。