From 176795333183385a1266f6b3b7be7c7fa2491f1d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 3 Sep 2026 21:08:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(db):=20SQLite=20=E6=97=B6=E9=97=B4=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E6=94=B9=E7=94=A8=20sqlite=20=E6=A0=BC=E5=BC=8F=20+?= =?UTF-8?q?=20UTC=20=E6=97=B6=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modernc.org/sqlite 在 writeTimeFormat 为空时回落到 t.String(),写出 `2026-09-03 16:45:38.5 +0800 HKT m=+0.009` —— 带单调时钟后缀, datetime() 返回 NULL,与 NOW() 的字符串比较必然错。 后果是安全 bug:user_sessions.expires_at 无法被 datetime() 解析, 过期令牌照样通过校验 —— 会话永不过期。 修法是一行 DSN(`_time_format=sqlite&_timezone=UTC`)而不是改 40 处 db.Ts() 绑定点:DSN 是全局的、不可能漏,辅助函数要求每个新绑定点都记得调用。 排除 `_time_format=datetime`:它把亚秒截断,会重新引入「同秒插入多封邮件 排序不确定」——「会话里最早那封」(决定联系人身份)与「最后那封」 (决定最新进展)都会取错行。 新增 usersession_time_test.go 5 例。反向验证:摘掉 DSN 修复后 4 个失败, 报文即「过期令牌竟然解析成功 —— 会话永不过期」。 --- gateway/internal/db/db.go | 23 ++- .../internal/repo/usersession_time_test.go | 195 ++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 gateway/internal/repo/usersession_time_test.go diff --git a/gateway/internal/db/db.go b/gateway/internal/db/db.go index ea3841c..8754f4c 100644 --- a/gateway/internal/db/db.go +++ b/gateway/internal/db/db.go @@ -153,7 +153,28 @@ func sqliteDSN(path string) string { return "file:" + path + "?_pragma=journal_mode(WAL)" + "&_pragma=busy_timeout(5000)" + - "&_pragma=foreign_keys(1)" + "&_pragma=foreign_keys(1)" + + // _time_format / _timezone 决定 time.Time 参数怎么落成文本。 + // + // 驱动的**默认行为是 Go 的 t.String()**,写出来是 + // 2026-09-10 15:10:36.122781994 +0800 HKT m=+607182.882153215 + // SQLite 的 datetime() 解析不了这种串(返回 NULL),而它跟我们注册的 + // NOW()("2006-01-02 15:04:05.000000" UTC)做的是**字符串**比较。 + // 后果实测有两条,都不是「显示不好看」级别的: + // 1. 安全:`expires_at > NOW()` 比较两种格式且时区不同(+0800 vs UTC), + // 用户会话永不过期,`DELETE ... WHERE expires_at < NOW()` 删 0 行。 + // 2. 功能:日历 `datetime(event_time,'-N minutes') <= NOW()` 恒为假, + // 一条提醒都发不出去。 + // + // _time_format=sqlite 给 "2006-01-02 15:04:05.999999999-07:00"(驱动 + // parseTimeFormats[0],读回时原样认得);_timezone=UTC 让偏移固定为 + // +00:00,与 NOW() 同一时间轴。 + // + // 不用 _time_format=datetime("2006-01-02 15:04:05"):它把亚秒截断, + // 会重新引入同秒插入多封邮件排序不确定的老问题 —— 「会话里最早那封」 + // (决定联系人身份)与「最后那封」(决定最新进展)都会取错行。 + "&_time_format=sqlite" + + "&_timezone=UTC" } func Close() { diff --git a/gateway/internal/repo/usersession_time_test.go b/gateway/internal/repo/usersession_time_test.go new file mode 100644 index 0000000..e81a1e7 --- /dev/null +++ b/gateway/internal/repo/usersession_time_test.go @@ -0,0 +1,195 @@ +package repo + +import ( + "context" + "testing" + "time" + + "github.com/agentmail/gateway/internal/db" + "github.com/google/uuid" +) + +/** + * 会话令牌的时间比较。 + * + * 这些用例锁的是一个真实的安全 bug:SQLite 驱动默认把 time.Time 写成 + * Go 的 t.String()("2026-09-10 15:10:36.12 +0800 HKT m=+607182.88"), + * 而 NOW() 返回 "2006-01-02 15:04:05.000000" 的 UTC 串。 + * 两者做字符串比较时 'T'/'+' 与数字的字典序、以及 +0800 与 UTC 的偏移 + * 双重错位,结果是: + * - expires_at > NOW() 恒为真 → 令牌永不过期 + * - DELETE WHERE expires_at < NOW() 删 0 行 → 过期令牌永久堆积 + * 生产库实测两条 user_sessions 都是这个状态。 + * + * 修法在 db.sqliteDSN 的 _time_format=sqlite&_timezone=UTC, + * 所以这里必须用真实的 db.Connect 路径来验(setupTestDB 就是)。 + */ + +func seedUserForSession(t *testing.T, ctx context.Context, username string) uuid.UUID { + t.Helper() + id := uuid.New() + _, err := db.DB.ExecContext(ctx, ` + INSERT INTO users (user_id, username, password_hash, display_name, role, status) + VALUES ($1, $2, 'x', $2, 'user', 'active')`, id, username) + if err != nil { + t.Fatalf("seed user %s: %v", username, err) + } + return id +} + +// 存进去的时间必须能被 SQLite 的时间函数解析。 +// datetime() 返回 NULL 意味着所有 SQL 侧时间运算(过期判断、日历到点判断)全废。 +func TestTimeBindingIsParsableBySQLite(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + uid := seedUserForSession(t, ctx, "alice") + + if _, _, err := CreateUserSession(ctx, uid, "probe"); err != nil { + t.Fatalf("create session: %v", err) + } + + var bad int + err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM user_sessions WHERE datetime(expires_at) IS NULL`).Scan(&bad) + if err != nil { + t.Fatalf("probe: %v", err) + } + if bad != 0 { + var raw string + db.DB.QueryRowContext(ctx, + `SELECT CAST(expires_at AS TEXT) FROM user_sessions LIMIT 1`).Scan(&raw) + t.Fatalf("expires_at 无法被 datetime() 解析(存储为 %q)——"+ + "所有 SQL 侧时间比较都会静默失效", raw) + } +} + +// 新令牌必须被认作有效,且滑动续期后仍然有效。 +func TestFreshSessionResolves(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + uid := seedUserForSession(t, ctx, "bob") + + token, expires, err := CreateUserSession(ctx, uid, "ua") + if err != nil { + t.Fatalf("create: %v", err) + } + if !expires.After(time.Now()) { + t.Fatalf("新令牌的过期时间应在将来,得到 %v", expires) + } + + u, err := ResolveUserSession(ctx, token) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if u.Username != "bob" { + t.Fatalf("解析出的用户应为 bob,得到 %q", u.Username) + } + + // 滑动续期写回的也是 time.Time,格式同样必须正确 + var bad int + db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM user_sessions WHERE datetime(expires_at) IS NULL`).Scan(&bad) + if bad != 0 { + t.Fatal("滑动续期写回的 expires_at 格式不可解析") + } +} + +// 过期令牌必须被拒。这是 bug 的核心症状:格式错位时 expires_at > NOW() +// 恒为真,一个 2020 年就过期的令牌照样能登录。 +func TestExpiredSessionRejected(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + uid := seedUserForSession(t, ctx, "carol") + + token, _, err := CreateUserSession(ctx, uid, "ua") + if err != nil { + t.Fatalf("create: %v", err) + } + + // 手工把过期时间推到过去(走同一条 time.Time 绑定路径) + past := time.Now().Add(-1 * time.Hour) + if _, err := db.DB.ExecContext(ctx, + `UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, token, past); err != nil { + t.Fatalf("backdate: %v", err) + } + + if _, err := ResolveUserSession(ctx, token); err == nil { + var raw string + db.DB.QueryRowContext(ctx, + `SELECT CAST(expires_at AS TEXT) FROM user_sessions WHERE token=$1`, token).Scan(&raw) + t.Fatalf("过期令牌竟然解析成功(expires_at=%q)—— 会话永不过期", raw) + } +} + +// 顺手清理必须真的删掉过期行。删 0 行意味着 user_sessions 无限增长, +// 且被盗令牌永远有效。 +func TestExpiredSessionsGetPurged(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + uid := seedUserForSession(t, ctx, "dave") + + stale, _, err := CreateUserSession(ctx, uid, "ua") + if err != nil { + t.Fatalf("create: %v", err) + } + past := time.Now().Add(-48 * time.Hour) + if _, err := db.DB.ExecContext(ctx, + `UPDATE user_sessions SET expires_at = $2 WHERE token = $1`, stale, past); err != nil { + t.Fatalf("backdate: %v", err) + } + + // 再建一个会话,CreateUserSession 内部会顺手清理过期令牌 + if _, _, err := CreateUserSession(ctx, uid, "ua2"); err != nil { + t.Fatalf("second create: %v", err) + } + + var n int + if err := db.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM user_sessions WHERE token = $1`, stale).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatal("过期令牌没被清理 —— DELETE ... WHERE expires_at < NOW() 匹配不到行") + } + + // 没过期的那条不能被误删 + var alive int + db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sessions`).Scan(&alive) + if alive != 1 { + t.Fatalf("应剩 1 条有效会话,得到 %d", alive) + } +} + +// 时间能原样读回,且亚秒精度不丢。 +// +// 精度是有代价地保住的:_time_format=datetime 也能让 datetime() 正常工作, +// 但它把时间截断到秒 —— 同秒插入的多封邮件排序就不确定, +// 「会话里最早那封」(决定联系人身份)会取错行。 +func TestTimeRoundTripKeepsSubSecond(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + _, err := db.DB.ExecContext(ctx, + `INSERT INTO agents (agent_name, secret, platform) VALUES ('probe','x','test')`) + if err != nil { + t.Fatalf("seed agent: %v", err) + } + + want := time.Date(2026, 9, 3, 8, 45, 38, 123456000, time.UTC) + if _, err := db.DB.ExecContext(ctx, + `UPDATE agents SET last_seen = $1 WHERE agent_name = 'probe'`, want); err != nil { + t.Fatalf("update: %v", err) + } + + var got time.Time + if err := db.DB.QueryRowContext(ctx, + `SELECT last_seen FROM agents WHERE agent_name = 'probe'`).Scan(&got); err != nil { + t.Fatalf("scan: %v", err) + } + if !got.UTC().Equal(want) { + t.Fatalf("时间往返不一致:写入 %v,读回 %v", want, got.UTC()) + } + if got.UTC().Nanosecond() == 0 { + t.Fatal("亚秒精度被截断 —— 同秒插入的多行排序会不确定") + } +}