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:
+2
-1
@@ -77,7 +77,8 @@ public class ChatSafetyService {
|
||||
// 1. 高风险关键词
|
||||
for (String word : HIGH_RISK_WORDS) {
|
||||
if (content.contains(word)) {
|
||||
log.warn("Chat content matched HIGH_RISK word: '{}'", word);
|
||||
// 不记录命中的具体词汇,避免将聊天内容片段写入普通日志(Phase 10 Gate)
|
||||
log.warn("Chat content matched HIGH_RISK keyword (blocked)");
|
||||
return ScanResult.high("检测到可能的诈骗/违规内容,消息已拦截");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,39 +126,39 @@ public class PhotoService {
|
||||
*/
|
||||
@Transactional
|
||||
public UserPhoto uploadAndSave(String userId, MultipartFile file, String photoType, int sortOrder) {
|
||||
// 1. 安全校验
|
||||
if ("local_eligibility".equals(photoType) && !isAllowedLocalEvidenceMimeType(file.getContentType())) {
|
||||
throw new RuntimeException("不支持的佐证文件格式: " + file.getContentType());
|
||||
}
|
||||
if (!isAllowedMimeType(file.getContentType())) {
|
||||
throw new RuntimeException("不支持的图片格式: " + file.getContentType());
|
||||
// 1. 类型与大小校验
|
||||
// local_eligibility 允许 PDF 等佐证格式,有自己的白名单;其余走图片白名单。
|
||||
// 历史缺陷:此前两条 MIME 校验串行执行,PDF 会被图片白名单挡掉;魔数校验又因
|
||||
// new byte[n] 从不读取流内容而恒判失败,导致本地资格佐证上传 100% 失败。
|
||||
String contentType = file.getContentType();
|
||||
boolean isLocalEvidence = "local_eligibility".equals(photoType);
|
||||
if (isLocalEvidence) {
|
||||
if (!isAllowedLocalEvidenceMimeType(contentType)) {
|
||||
throw new RuntimeException("不支持的佐证文件格式: " + contentType);
|
||||
}
|
||||
} else if (!isAllowedMimeType(contentType)) {
|
||||
throw new RuntimeException("不支持的图片格式: " + contentType);
|
||||
}
|
||||
if (!validateSize(file, photoType)) {
|
||||
throw new RuntimeException("图片大小超过限制(最大10MB)");
|
||||
}
|
||||
InputStream stream;
|
||||
try {
|
||||
stream = file.getInputStream();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取文件失败", e);
|
||||
}
|
||||
byte[] header;
|
||||
try {
|
||||
header = new byte[Math.min(12, stream.available())];
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取文件头失败", e);
|
||||
}
|
||||
if (!validateMagicNumber(header)) {
|
||||
throw new RuntimeException("无效的图片文件头");
|
||||
}
|
||||
try {
|
||||
stream.reset();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("重置输入流失败", e);
|
||||
throw new RuntimeException("文件大小超过限制");
|
||||
}
|
||||
|
||||
// 2. 生成 key 并上传
|
||||
String fileKey = generateFileKey(userId, photoType);
|
||||
// 2. 魔数校验(仅图片;PDF 佐证跳过——Content-Type 已由白名单约束,
|
||||
// 且其魔数 %PDF- 与图片魔数不同,混用同一校验会误杀)
|
||||
if (!isLocalEvidence) {
|
||||
byte[] header;
|
||||
try (InputStream headerStream = file.getInputStream()) {
|
||||
header = headerStream.readNBytes(12);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("读取文件头失败", e);
|
||||
}
|
||||
if (header.length < 3 || !validateMagicNumber(header)) {
|
||||
throw new RuntimeException("无效的图片文件头");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 生成 key 并上传(使用全新输入流,与校验流隔离,互不干扰)
|
||||
String fileKey = generateFileKey(userId, photoType, contentType);
|
||||
try {
|
||||
amazonS3.putObject(privateBucket, fileKey, file.getInputStream(),
|
||||
new com.amazonaws.services.s3.model.ObjectMetadata() {{
|
||||
@@ -169,7 +169,7 @@ public class PhotoService {
|
||||
throw new RuntimeException("上传文件失败", e);
|
||||
}
|
||||
|
||||
// 3. 保存记录
|
||||
// 4. 保存记录
|
||||
return savePhoto(userId, fileKey, photoType, sortOrder);
|
||||
}
|
||||
|
||||
@@ -285,8 +285,27 @@ public class PhotoService {
|
||||
}
|
||||
|
||||
private String generateFileKey(String userId, String photoType) {
|
||||
// 从文件头推断扩展名
|
||||
String ext = ".jpg";
|
||||
// 直传凭证路径:客户端尚未上传,Content-Type 未知,沿用默认扩展名
|
||||
return String.format("photos/%s/%s/%s.jpg",
|
||||
userId,
|
||||
photoType,
|
||||
UUID.randomUUID().toString().replace("-", ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Content-Type 推断扩展名,避免 PDF 佐证拿到 .jpg 这类误导性 key
|
||||
* (对象元数据虽记录了正确的 Content-Type,但 key 后缀仍会被审核下载复用)。
|
||||
*/
|
||||
private String generateFileKey(String userId, String photoType, String contentType) {
|
||||
String ext = switch (contentType == null ? "" : contentType.toLowerCase()) {
|
||||
case "image/jpeg" -> ".jpg";
|
||||
case "image/png" -> ".png";
|
||||
case "image/webp" -> ".webp";
|
||||
case "image/gif" -> ".gif";
|
||||
case "image/heic" -> ".heic";
|
||||
case "application/pdf" -> ".pdf";
|
||||
default -> "";
|
||||
};
|
||||
return String.format("photos/%s/%s/%s%s",
|
||||
userId,
|
||||
photoType,
|
||||
|
||||
Reference in New Issue
Block a user