背景
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。
影响评估
推荐修法
把 `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 加固一起做。
关联
背景
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。
影响评估
推荐修法
把 `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 加固一起做。
关联