diff --git a/AUDIT-2026-09-22.md b/AUDIT-2026-09-22.md index ec72bc7..a7d1ec4 100644 --- a/AUDIT-2026-09-22.md +++ b/AUDIT-2026-09-22.md @@ -15,6 +15,7 @@ IPv6-only 接入(`ssh -6 -i ~/.ssh/id_ed25519 root@fdb2:2c26:f4e4:0:21c:42ff:f | 5 | 依赖漏洞:pgx v5.7.1(GO-2026-5004,可达 pgrepo.go)、x/net、x/text | govulncheck | 升 pgx v5.9.2 / x/net v0.55.0 / x/text v0.39.0 | | 16 | **`ConsumeLoginEmailOTP` PG 实现与接口契约漂移**:契约/`fakeRepo` 说「同 VerifyEmailOTP 生命周期(扣尝试/锁定/ErrOTPInvalid/ErrOTPLocked)」,PG 却是单条 UPDATE+`ErrNotFound` → 错码/重放/过期在 login 门、op-login finish、migration confirm 三个入口全部 500;且尝试次数永不累计、`otpMaxAttempts` 锁定失效 | 真机:login 门重放正确码 → **HTTP 500**;修复前错码不扣次。修复后复测:5 次错码 400 且 attempts=5(正确码因锁定也 400、码未消费)、新码可用、重放 400 | `pgrepo.go` 改置为 VerifyEmailOTP 同构事务(FOR UPDATE、先锁后比、mismatch 扣次、match 消费),无 users 写副作用。提交 `52549f7` | | 17 | **`ListPendingOpLogins` PG 少列**:接口注释承诺「joined to its staff username」,handler 输出 `username`/`created_at`,fake 正确填充;PG SQL 未 JOIN 也未取 `created_at` → 真机 pending 列表 username 为空、created_at 为 `0001-01-01` | 真机 internal `/op-login/pending` 响应 | `pgrepo.go` SQL 改为 JOIN users + 取 created_at。提交见分支 | +| 18 | **绑定码并发兑换 500**:`RedeemPlayerBindCode` 无 `FOR UPDATE`(同文件 `VerifyLinkCode` 有),且裸 INSERT。6 路并发同码兑换 → 3×HTTP 500(`users_username_key` 唯一冲突)+ 1×400 + 2×200;跨码并发同 UUID 同样会撞 | 真机四组并发测试 + API 日志 `unmapped error ... duplicate key` | 同码:码行 `FOR UPDATE`(输家干净地 400 invalid_code);跨码:`INSERT users ... ON CONFLICT (username) DO NOTHING`+重读、`account_links ON CONFLICT (mc_uuid) DO NOTHING`(两路汇聚同一 user)。复测:同码×6=1×200+5×400、双码×2=2×200 同 ID、DB 干净、日志零 unmapped | ## 待决策台账(未修) @@ -40,6 +41,9 @@ IPv6-only 接入(`ssh -6 -i ~/.ssh/id_ed25519 root@fdb2:2c26:f4e4:0:21c:42ff:f - **毒档案 fail-closed**:穿越/绝对路径/符号链接条目 → `archive entry escapes target` 退出码 1,零写入 ✅ - 混沌:PG 掉线(healthz 仍 200、恢复后连接池自愈)、API pod 击杀(~2s 中断)、整机重启(32s 回归、会话/CRD/停止态全保留)✅ - `felis update` 报告(k3s/velocity 有更新、私有仓库 404 优雅处理)✅ +- 账户全套真机 E2E:绑定码新玩家注册(幂等/并发见 #18)、邮箱验证(onboarding 门)、邮箱 OTP 登录(错码扣次/5 次锁定后正确码也 400、重放 400、staff 账号 403 拒绝)、Passkey 注册+discoverable 登录+邮箱优先登录(虚拟认证器;Chrome 要求 `Page.bringToFront` 才能过 focus 检查)、登出吊销会话(旧 cookie 401) +- op-login 全状态机(start→status→approve→finish;早 finish 不烧码、错码扣次且请求保留、重放/重复批准/非管理员批准/未知 handle 全部按契约返回)✅ +- 并发:OTP 风暴 8 路 = 1×202 + 7×429 且仅铸 1 码;绑定码并发(同码/双码)见 #18 ✅ ## 复现入口速查 diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index becf772..22c54b9 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -146,13 +146,17 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin var mcUUID, authSource string switch err := tx.QueryRowContext(ctx, - `SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`, + `SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2 + FOR UPDATE`, code, now).Scan(&mcUUID, &authSource); { case errors.Is(err, sql.ErrNoRows): return "", "", "", ErrLinkCodeInvalid case err != nil: return "", "", "", err } + // The lock above serialises redeemers of ONE code; the ON CONFLICT arms below + // cover the rarer cross-code race (two live codes for the same UUID redeemed + // together), where both transactions reach the inserts before either commits. // Create-or-fetch keyed on the verified UUID. An already-linked role='user' player // is fetched (idempotent "log in via the game"); a role='admin' STAFF account is @@ -165,16 +169,27 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin mcUUID).Scan(&userID, &existingRole); { case errors.Is(err, sql.ErrNoRows): if _, err := tx.ExecContext(ctx, - `INSERT INTO users (id, username, role) VALUES ($1, $2, 'user')`, + `INSERT INTO users (id, username, role) VALUES ($1, $2, 'user') + ON CONFLICT (username) DO NOTHING`, newUserID, mcUUID); err != nil { return "", "", "", fmt.Errorf("create player: %w", err) } + // Re-read by username so a cross-code race converges on the winner's row + // (our id was discarded by DO NOTHING) instead of a bare 500. + var role string + if err := tx.QueryRowContext(ctx, + `SELECT id, role::text FROM users WHERE username = $1`, mcUUID).Scan(&userID, &role); err != nil { + return "", "", "", fmt.Errorf("create player: %w", err) + } + if role != "user" { + return "", "", "", ErrPlayerBindForbidden + } if _, err := tx.ExecContext(ctx, - `INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)`, - newUserID, mcUUID, authSource); err != nil { + `INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3) + ON CONFLICT (mc_uuid) DO NOTHING`, + userID, mcUUID, authSource); err != nil { return "", "", "", fmt.Errorf("write account link: %w", err) } - userID = newUserID case err != nil: return "", "", "", err default: