Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
* 即可往 victim 历史里塞消息。COALESCE 语义不会改 ownerId,但 INSERT INTO
* "Message" 已经发生——这是数据完整性 + 内容污染问题。
*
* 防御深度:controller 层前置校验(快速拦截)+ repository 层 SQL WHERE 子句
* 原子校验(fix #27 TOCTOU,消除 lookupOwner 与 saveTurn 之间的竞态窗口)。
*
* 见 SecurityInvariantsTests INV-002 三条断言。
*/
@RestController
Expand All @@ -49,6 +52,7 @@ public ApiResponse<Void> save(@RequestBody ChatTurnSaveRequest req) {
// INV-002:归属校验。已绑定 owner 的 chat 必须由 owner 本人写。
// 这一步必须在 saveTurn 之前——saveTurn 内部用 ON CONFLICT upsert,
// 一旦执行就会插入 Message 行,事后回滚得靠 @Transactional,宁可前置拦截。
// SQL WHERE 子句兜底 TOCTOU(fix #27)。
Optional<ChatOwner> existing = chatHistoryRepository.lookupOwner(req.chatId());
if (existing.isPresent() && !existing.get().isAnonymous()) {
Long ownerId = existing.get().ownerId();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -53,17 +54,23 @@ public Optional<ChatOwner> 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);
Expand All @@ -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);
}
Expand Down