Skip to content

Commit 14b3fc2

Browse files
authored
Merge pull request #32 from InvolutionHell/fix/chat-toctou
fix(chat): saveTurn SQL WHERE 消除 TOCTOU 竞态
2 parents 2d902d0 + 7874972 commit 14b3fc2

2 files changed

Lines changed: 16 additions & 1 deletion

File tree

src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
* 即可往 victim 历史里塞消息。COALESCE 语义不会改 ownerId,但 INSERT INTO
2525
* "Message" 已经发生——这是数据完整性 + 内容污染问题。
2626
*
27+
* 防御深度:controller 层前置校验(快速拦截)+ repository 层 SQL WHERE 子句
28+
* 原子校验(fix #27 TOCTOU,消除 lookupOwner 与 saveTurn 之间的竞态窗口)。
29+
*
2730
* 见 SecurityInvariantsTests INV-002 三条断言。
2831
*/
2932
@RestController
@@ -49,6 +52,7 @@ public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) {
4952
// INV-002:归属校验。已绑定 owner 的 chat 必须由 owner 本人写。
5053
// 这一步必须在 saveTurn 之前——saveTurn 内部用 ON CONFLICT upsert,
5154
// 一旦执行就会插入 Message 行,事后回滚得靠 @Transactional,宁可前置拦截。
55+
// SQL WHERE 子句兜底 TOCTOU(fix #27)。
5256
Optional<ChatOwner> existing = chatHistoryRepository.lookupOwner(req.chatId());
5357
if (existing.isPresent() && !existing.get().isAnonymous()) {
5458
Long ownerId = existing.get().ownerId();

src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.involutionhell.backend.chat.repository;
22

3+
import com.involutionhell.backend.common.error.AccessDeniedBusinessException;
34
import java.sql.Types;
45
import java.util.List;
56
import java.util.Optional;
@@ -53,17 +54,23 @@ public Optional<ChatOwner> lookupOwner(String chatId) {
5354
* 第二次带着真实 userId 过来时应该把之前的 NULL 覆盖掉;但如果这次匿名、
5455
* 上次已经登录了,不能把 userId 擦掉——所以用 COALESCE(EXCLUDED.userId, "Chat"."userId")
5556
* 的语义:新值优先,新值为 NULL 时保留旧值。
57+
*
58+
* WHERE 子句(fix #27 TOCTOU):归属校验在 SQL 层原子完成——ON CONFLICT
59+
* 命中时,只有 owner 兼容(NULL 或相同 userId)才允许 UPDATE。不兼容时
60+
* affected rows = 0,直接抛 AccessDeniedBusinessException,Message 不插入。
61+
* 消除了 controller 层 lookupOwner 与 saveTurn 之间的竞态窗口。
5662
*/
5763
@Override
5864
@Transactional
5965
public void saveTurn(String chatId, Long userId, String userMessage, String assistantMessage) {
60-
jdbc.update(
66+
int rows = jdbc.update(
6167
"""
6268
INSERT INTO "Chat" (id, "userId", "createdAt", "updatedAt")
6369
VALUES (?, ?, NOW(), NOW())
6470
ON CONFLICT (id) DO UPDATE SET
6571
"userId" = COALESCE(EXCLUDED."userId", "Chat"."userId"),
6672
"updatedAt" = NOW()
73+
WHERE "Chat"."userId" IS NULL OR "Chat"."userId" = EXCLUDED."userId"
6774
""",
6875
ps -> {
6976
ps.setString(1, chatId);
@@ -74,6 +81,10 @@ ON CONFLICT (id) DO UPDATE SET
7481
}
7582
});
7683

84+
if (rows == 0) {
85+
throw new AccessDeniedBusinessException("不允许写入他人的 chat 历史");
86+
}
87+
7788
if (userMessage != null && !userMessage.isBlank()) {
7889
insertMessage(chatId, "user", userMessage);
7990
}

0 commit comments

Comments
 (0)