diff --git a/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java b/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java index 1e1b874..6381274 100644 --- a/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java +++ b/src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java @@ -24,6 +24,9 @@ * 即可往 victim 历史里塞消息。COALESCE 语义不会改 ownerId,但 INSERT INTO * "Message" 已经发生——这是数据完整性 + 内容污染问题。 * + * 防御深度:controller 层前置校验(快速拦截)+ repository 层 SQL WHERE 子句 + * 原子校验(fix #27 TOCTOU,消除 lookupOwner 与 saveTurn 之间的竞态窗口)。 + * * 见 SecurityInvariantsTests INV-002 三条断言。 */ @RestController @@ -49,6 +52,7 @@ public ApiResponse save(@RequestBody ChatTurnSaveRequest req) { // INV-002:归属校验。已绑定 owner 的 chat 必须由 owner 本人写。 // 这一步必须在 saveTurn 之前——saveTurn 内部用 ON CONFLICT upsert, // 一旦执行就会插入 Message 行,事后回滚得靠 @Transactional,宁可前置拦截。 + // SQL WHERE 子句兜底 TOCTOU(fix #27)。 Optional existing = chatHistoryRepository.lookupOwner(req.chatId()); if (existing.isPresent() && !existing.get().isAnonymous()) { Long ownerId = existing.get().ownerId(); diff --git a/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java b/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java index 61fc1a7..2e7d21d 100644 --- a/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java +++ b/src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java @@ -1,5 +1,6 @@ package com.involutionhell.backend.chat.repository; +import com.involutionhell.backend.common.error.AccessDeniedBusinessException; import java.sql.Types; import java.util.List; import java.util.Optional; @@ -53,17 +54,23 @@ public Optional lookupOwner(String chatId) { * 第二次带着真实 userId 过来时应该把之前的 NULL 覆盖掉;但如果这次匿名、 * 上次已经登录了,不能把 userId 擦掉——所以用 COALESCE(EXCLUDED.userId, "Chat"."userId") * 的语义:新值优先,新值为 NULL 时保留旧值。 + * + * WHERE 子句(fix #27 TOCTOU):归属校验在 SQL 层原子完成——ON CONFLICT + * 命中时,只有 owner 兼容(NULL 或相同 userId)才允许 UPDATE。不兼容时 + * affected rows = 0,直接抛 AccessDeniedBusinessException,Message 不插入。 + * 消除了 controller 层 lookupOwner 与 saveTurn 之间的竞态窗口。 */ @Override @Transactional public void saveTurn(String chatId, Long userId, String userMessage, String assistantMessage) { - jdbc.update( + int rows = jdbc.update( """ INSERT INTO "Chat" (id, "userId", "createdAt", "updatedAt") VALUES (?, ?, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET "userId" = COALESCE(EXCLUDED."userId", "Chat"."userId"), "updatedAt" = NOW() + WHERE "Chat"."userId" IS NULL OR "Chat"."userId" = EXCLUDED."userId" """, ps -> { ps.setString(1, chatId); @@ -74,6 +81,10 @@ ON CONFLICT (id) DO UPDATE SET } }); + if (rows == 0) { + throw new AccessDeniedBusinessException("不允许写入他人的 chat 历史"); + } + if (userMessage != null && !userMessage.isBlank()) { insertMessage(chatId, "user", userMessage); }