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 未启动,按计划每阶段出报告后停止等待确认。
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
||||
package com.danyang.match.platform.photo;
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3;
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata;
|
||||
import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||
import com.danyang.match.platform.photo.service.PhotoService;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 图片上传安全校验测试(Phase 10: Image & Chat Safety)。
|
||||
*
|
||||
* <p>历史缺陷(D1):{@code uploadAndSave} 此前用 {@code new byte[n]} 分配缓冲却从不
|
||||
* 调用 {@code stream.read(...)},{@code validateMagicNumber} 恒收到全零字节 → 恒返回 false
|
||||
* → 本地资格佐证上传 100% 失败;同时 image-only 的 {@code isAllowedMimeType} 在
|
||||
* local_eligibility 之后无条件执行,PDF 被挡。本测试用 {@link MockMultipartFile} 真正走一遍
|
||||
* 校验链路,确保魔数/MIME/大小守卫在运行时实际生效——单测 {@code validateMagicNumber} 本身
|
||||
* (传入手写字节)无法暴露这条路径缺陷。
|
||||
*/
|
||||
class PhotoUploadValidationTest {
|
||||
|
||||
/** JPEG 魔数:FF D8 FF E0 + 最小可用体 */
|
||||
private static final byte[] JPEG_BYTES = {
|
||||
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0
|
||||
};
|
||||
|
||||
private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) {
|
||||
return new PhotoService(mapper, s3, flags);
|
||||
}
|
||||
|
||||
private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) {
|
||||
FeatureFlagConfig f = mock(FeatureFlagConfig.class);
|
||||
when(f.getLocalEvidenceMaxFileSizeMB()).thenReturn(maxMB);
|
||||
when(f.getLocalEvidenceAllowedTypes()).thenReturn(allowedTypes);
|
||||
return f;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("合法 JPEG 通过 MIME + 魔数校验(修复前此用例会因 header 恒零而失败)")
|
||||
void validJpegPassesValidation() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||
PhotoService svc = newService(s3, mapper, null);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "a.jpg", "image/jpeg", JPEG_BYTES);
|
||||
|
||||
UserPhoto result = svc.uploadAndSave("u1", file, "lifestyle", 0);
|
||||
|
||||
assertNotNull(result);
|
||||
verify(s3).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("伪造 Content-Type(声称 image/jpeg 实为文本)被魔数校验拦截")
|
||||
void spoofedContentTypeRejectedByMagic() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
PhotoService svc = newService(s3, mapper, null);
|
||||
|
||||
// 声称是 JPEG,实际内容是纯文本
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "evil.jpg", "image/jpeg", "not a real image".getBytes());
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> svc.uploadAndSave("u1", file, "lifestyle", 0));
|
||||
assertTrue(ex.getMessage().contains("无效的图片文件头"),
|
||||
"应因魔数不匹配被拒,实际: " + ex.getMessage());
|
||||
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PDF 本地资格佐证不被 image-only 校验挡,且跳过图片魔数校验")
|
||||
void pdfLocalEligibilityNotBlocked() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||
FeatureFlagConfig flags = localEvidenceFlags(10, "jpg,png,pdf");
|
||||
PhotoService svc = newService(s3, mapper, flags);
|
||||
|
||||
// application/pdf 走 local_eligibility 白名单(含 pdf),不应被 image-only 挡
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "evidence.pdf", "application/pdf",
|
||||
"%PDF-1.4 fake pdf content".getBytes());
|
||||
|
||||
UserPhoto result = svc.uploadAndSave("u1", file, "local_eligibility", 0);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getFileKey().endsWith(".pdf"),
|
||||
"PDF key 应以 .pdf 结尾,实际: " + result.getFileKey());
|
||||
verify(s3).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非法 MIME 类型被拒(text/plain 当作 lifestyle 图片)")
|
||||
void invalidMimeTypeRejected() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
PhotoService svc = newService(s3, mapper, null);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "a.txt", "text/plain", JPEG_BYTES);
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> svc.uploadAndSave("u1", file, "lifestyle", 0));
|
||||
assertTrue(ex.getMessage().contains("不支持的图片格式"),
|
||||
"应因 MIME 不在白名单被拒,实际: " + ex.getMessage());
|
||||
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("超过大小限制被拒(local_eligibility 限额 1MB,传入 2MB)")
|
||||
void oversizedFileRejected() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
FeatureFlagConfig flags = localEvidenceFlags(1, "pdf");
|
||||
PhotoService svc = newService(s3, mapper, flags);
|
||||
|
||||
// 2MB 的 PDF 佐证,限额 1MB
|
||||
byte[] big = new byte[2 * 1024 * 1024];
|
||||
big[0] = '%';
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "big.pdf", "application/pdf", big);
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||
() -> svc.uploadAndSave("u1", file, "local_eligibility", 0));
|
||||
assertTrue(ex.getMessage().contains("文件大小超过限制"),
|
||||
"应因超限被拒,实际: " + ex.getMessage());
|
||||
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PNG 魔数校验通过")
|
||||
void validPngPassesValidation() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||
PhotoService svc = newService(s3, mapper, null);
|
||||
|
||||
byte[] png = {
|
||||
(byte) 0x89, (byte) 0x50, (byte) 0x4E, (byte) 0x47,
|
||||
(byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A,
|
||||
0, 0, 0, 0
|
||||
};
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "a.png", "image/png", png);
|
||||
|
||||
UserPhoto result = svc.uploadAndSave("u1", file, "lifestyle", 0);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getFileKey().endsWith(".png"));
|
||||
verify(s3).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user