Skip to content

chat history saveTurn TOCTOU: lookupOwner 与 saveTurn 不在同一事务 #27

@longsizhuo

Description

@longsizhuo

背景

PR #26 引入 INV-002 阻断"匿名/登录用户往他人 chat 历史写消息"。Copilot CR 指出 controller 层的 lookupOwner + saveTurn 两步不在同一事务,存在 TOCTOU race window:

```
T1 (匿名) : lookupOwner('chat-X') → empty
T2 (alice) : saveTurn('chat-X', alice_id, ...) → INSERT INTO Chat WITH alice_id
T1 (匿名) : saveTurn('chat-X', null, ...)
→ ON CONFLICT DO UPDATE userId = COALESCE(null, alice) = alice
+ INSERT INTO Message
```

T1 的 message 进了 alice 的 chat。

影响评估

  • 利用条件极苛刻:受害者与攻击者必须毫秒级并发同一 UUID chatId(chatId 是 `crypto.randomUUID` 生成,需 victim 主动 leak)
  • 最坏后果:一条 user message 错挂到 victim chat(不是会话接管、不是数据破坏)
  • 与 INV-002 阻止的"任意越权写入"严重程度差一个数量级,所以 PR chore(security): 用户中心 / chat / 密码 / compose 多处姿态收紧 #26 不修

推荐修法

把 `lookupOwner + saveTurn` 下沉到 service 层,包在同一 `@Transactional` 方法内,并任选其一:

方案 A(推荐):`saveTurn` SQL 加 owner WHERE 子句

```sql
-- Chat upsert 仅在 owner 兼容时命中
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"
RETURNING "userId"
```

返回行数 0 → 抛 `AccessDeniedBusinessException`,message 不插入。

方案 B:service 层 `SELECT ... FOR UPDATE` 锁定 chat row 后再写
适用于已存在的 chat;新 chat 仍走插入路径。

方案 C:`@Transactional(isolation = SERIALIZABLE)` + retry
PG SSI 会在 commit 时检测 conflict 并 abort,但需要应用层重试,复杂度更高。

测试

需要新增并发测试模拟 race 场景:

```java
@test
void saveTurn防TOCTOU_并发匿名写不能污染他人chat() {
// 用 CountDownLatch 让两个 thread 在 lookupOwner 之间夹一个并发 saveTurn
// 期望:被夹在中间的"恶意匿名写"被拒,victim chat 不污染
}
```

优先级

P2(基于影响评估)。建议在下次 sprint 跟 INV-FE-001 的 CSP 加固一起做。

关联

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions