From 095213b98115c19b60b8ee2255cfe05fc37c7009 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 13:27:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8)=E2=98=85=E2=98=85:=20?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=88=AB=E4=BA=BA=E7=9A=84=20session=5Fid=20?= =?UTF-8?q?=E5=B0=B1=E8=83=BD=E8=AF=BB=E9=82=A3=E5=B0=81=E4=BF=A1=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=A1=A5=E3=80=8C=E5=8F=82=E4=B8=8E?= =?UTF-8?q?=E6=96=B9=E3=80=8D=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 漏洞(实测,生产可利用) 对照实验(同一会话、同一 Agent,绕开 MCP 直打原生端点): 主人 mcp-peer 读自己的信(带正确 session_id) ⇒ 200(正常) 他人 mcp-probe 读同一封信(**带正确 session_id**) ⇒ 200 ★ 泄露正文 绕开 MCP、直接 `GET /api/v1/agent/mail/{id}?session_id=...` 同样 200 ⇒ 根因在网关,不在任何接入方式。 生产复核(现行 8180,未改任何代码): dsh 声明 gui-lab 的 session_id ⇒ HTTP 200,拿到完整正文 ## 根因:判据里没有「你是谁」这一项 上一版(今天早些时候,15e4fe9 那次)只把 `if scope == nil { return true }` 改成拒绝,堵住的是「**不声明** session_id 就放行」。剩下的半边是 「声明一个**别人的** session_id」。 判据只有一句 `*scope != target` —— 它问的是「你声明的会话是不是目标会话」, 而 `session_id` **由请求方自己给**。于是任何持有 Agent 凭据的客户端只要报出 一个已存在的会话 id,就能以那条会话的身份读它。 漏洞的形状就写在签名里:函数**收了** `agentName`,却被 `_ = agentName` 丢弃。 ## 「信任边界在桥」这个前提不成立 函数头原来写着「信任边界在**桥**:`session_id` 由 worker 闭包注入(模型改不了)」。 那是对我们自家四个桥的陈述,**不是**服务端能强制的事实: 1. 桥与网关之间是普通 HTTP。任何拿到 Agent 凭据的客户端都能直接调这些端点 (本次实测即是如此)。 2. 「由闭包注入」是对我们自己代码的信心,不是收到请求时能重新验证的事实。 上一版收口时也用过同类理由(「迁移期未结束」),实测同样不成立 —— 这是同一天内第二次。 ## 修法:把「声明」变成可验证的事实 保留 `*scope != target`(2026-09-15 裁定:会话是独立单位、不跨会话读取), **另加**一道参与方校验: scope == target 且 agentName ∈ 该会话的参与方(from / to / cc) ⇒ 放行 - 不引入工作区轴(那是 cwd/沙箱那条轴,2026-09-15 明确划开)。 - 不新建表、不加迁移。 - 参与方判定与 `SessionParticipants` 同源(逐封扫 mails,同一个 `models.Address` JSON 解析),避免两处对同一份数据给出不同答案。 - 抄送方算参与方:生产里有 2622 行非空 cc_list,只认 from/to 会把正当读者判成外人。 ## fail closed 查参与方出错(DB 不可用、cc_list 解析失败)一律拒绝并带错误。 这道闸的失败模式必须是沉默的拒绝 —— 一旦「查不到就放行」, 数据库一抖就等于把漏洞重新打开,且没有任何日志。 ## 判据(9 格,含改写) 改写 2 格:`AllowsOwnSession` 原来用 `uuid.New()` 造一条**不存在的**会话 就断言放行 —— 那正是漏洞的形状;`IgnoresAgentName` 断言「身份不参与判断」, **这条断言本身就是漏洞**。两者都改成断言新事实。 新增 7 格,覆盖:声明别人会话被拒 / 抄送方放行 / 主收件方放行 / 空身份拒绝 / 判定随身份改变 / 跨会话仍拒(参与方也不行)/ fail closed。 **变异验证**(每条确认已应用后才数红格): 退回漏洞原状(跳过参与方校验) → 红 3 只认 from_agent(漏 to 与 cc) → 红 1 ★(先测时红格为 0,补了主收件方那格才抓住) fail open(查不到就放行) → 红 1 ★(同样先红格为 0,补了 fail-closed 那格) 后两条是**补判据的过程**:`return true` 那版和「只看 from」那版都能全套通过, 说明原先的判据盯不住这两个改法。 ## 对现有桥的影响(部署前实测) 按生产数据核对四个桥:「from_agent 是它、但它不是任何邮件参与方」的会话 只有 1 条,且**零邮件**(一条权限请求测试会话)—— 那类会话没有邮件可读, 判据影响为零。桥不会被误伤。 ## 波及面 `AgentMayReadSession` 有 5 个消费点:read_mail / read_thread / 读会话参与者 / forward 的源信 / AgentGetMailThread 另一分支。全部自动获得这道判据。 全量 14 包绿。 --- server/internal/repo/session_workspace.go | 149 ++++++++++- .../internal/repo/session_workspace_test.go | 246 ++++++++++++++++-- 2 files changed, 368 insertions(+), 27 deletions(-) diff --git a/server/internal/repo/session_workspace.go b/server/internal/repo/session_workspace.go index d346389..23a43ef 100644 --- a/server/internal/repo/session_workspace.go +++ b/server/internal/repo/session_workspace.go @@ -2,8 +2,10 @@ package repo import ( "context" + "encoding/json" "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) @@ -86,16 +88,82 @@ func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string { // // # 为什么不能靠「补齐调用方」来收口 // -// 那要同时改四个桥(其中 pi 的 `getMailSessionId` 有 `= () => ''` 的默认值, +// 那要同时改四个桥(其中 pi 的 `getMailSessionId` 有 `= () => ”` 的默认值, // 忘了注入就是静默空串 ⇒ 又回到裸奔),任何一处漏了 = 静默越权。 // **默认放行**与**默认拒绝**的差别就在这里:前者的失败模式是沉默的。 // // 收口后调用方拿到的错误文案见 canReadSession 的 not-your-session 分支。 +// +// ★★★ 2026-10-02(当天第二次):补上「参与方」判据 —— 上一版仍是一个**可利用的洞**。 +// +// # 上一版修好了什么、没修什么 +// +// 上一版(今天早些时候)把 `if scope == nil { return true }` 改成拒绝, +// 堵住的是「**不声明** session_id 就放行」。实测当时确实收住了: +// dsh 不带参数读 20 封别人的信 ⇒ 20/20 全部 403。 +// +// 但那只堵了一半。剩下的半边是:**声明一个别人的 session_id**。 +// +// # 实测(网关内置 MCP 的端到端验证顺带撞出来的) +// +// 对照实验(同一个 session,同一个 Agent): +// +// 主人 mcp-peer 读自己的信,带正确的 session_id ⇒ 200(正常) +// 他人 mcp-probe 读同一封信,**带正确的 session_id** ⇒ 200 ★ 泄露正文 +// +// 绕开 MCP、直接打原生端点(`GET /api/v1/agent/mail/{id}?session_id=...`) +// 同样 200 ⇒ 根因在网关,不在 MCP。 +// +// 生产复核(现行 8180 实例,未改动任何代码): +// +// dsh 声明 gui-lab 的 session_id ⇒ HTTP 200,拿到完整正文 +// (那封信的主题是「WebUI 的 CalendarView.tsx / index.css 是你在改吗」) +// +// # 根因:判据里没有「你是谁」这一项 +// +// 上一版的判据只有一句 `*scope != target`。它问的是「你声明的会话是不是目标会话」 +// —— 而 `session_id` 是**请求方自己给的**。于是任何持有 Agent 凭据的客户端 +// 只要报出一个已存在的会话 id,就能以那条会话的身份读它。 +// +// 函数签名里其实**收了** agentName,但被 `_ = agentName` 丢弃了 +// —— 漏洞的形状就在那一行。 +// +// # 「信任边界在桥」这个前提不成立 +// +// 函数头原来写着:「信任边界在**桥**:`session_id` 由 worker 闭包注入(模型改不了)」。 +// 那是关于**我们自家四个桥**的陈述,而它**不是**服务端能强制的事实: +// +// 1. 桥与网关之间是普通 HTTP。任何拿到 Agent 凭据(密钥或 name+secret)的 +// 客户端都能直接调这些端点,绕开桥。(本次实测就是如此。) +// 2. 就算不绕过桥,“session_id 由闭包注入”也只是**我们对自己代码的信心**, +// 不是服务端收到请求时能重新验证的事实。安全边界不能建立在 +// “对方会守规矩”上—— 上一版收口时已经用过同一条理由 +// (“迁移期未结束”),实测证明它同样不成立。 +// +// # 修法:判据改为「声明的那条会话,你确实是参与方」 +// +// 保留 `*scope != target`(会话是独立单位、不做跨会话读取——2026-09-15 的裁定), +// **另加**一道参与方校验。两个条件都成立才放行: +// +// scope == target 且 agentName ∈ 该会话的参与方 ⇒ 放行 +// 否则 ⇒ 拒绝 +// +// 这样仍然尊重 2026-09-15 的裁定(不按工作区、不引入跨会话仲裁), +// 同时把「声明身份」变成了**可验证的**事实而不是声明。 +// +// 为什么是参与方而不是别的判据: +// +// - 「是否为参与方」正好对应「这封信与你有关」,且 `SessionParticipants` +// 已经逐封扫出全部 from/to/cc,改动小且与前端那套参与方列表同源。 +// - 不引入工作区轴(那是 cwd/沙箱那条轴,2026-09-15 明确划开)。 +// - 不需要新建表或迁移。 +// +// 保留 `scope != target` 的理由:即使一个 Agent 参与了 20 条会话, +// 它也只该在**当前所在的那条**里读;跨会话读取正是 2026-09-15 要消除的 +// “相对私有化”设施。 func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID, target uuid.UUID) (bool, string, error) { - _ = ctx - _ = agentName if scope == nil { - // ★ 2026-10-02:由 `return true`(旧语义放行)改为**拒绝**。 + // ★ 2026-10-02(第一次):由 `return true`(旧语义放行)改为**拒绝**。 // // 理由见函数头:实测 dsh 不带 session_id 能读 20/20 封别人的信, // 而那 202 次裸奔里 202 次来自四个桥自己 ⇒ 「迁移期」早已结束。 @@ -109,5 +177,78 @@ func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID if *scope != target { return false, "not-your-session", nil } + + // ★ 2026-10-02(第二次):声明了会话还不够,还得**确实是这条会话的参与方**。 + // + // 这一格曾被 `_ = agentName` 丢弃,而它正是漏洞所在:session_id 由请求方提供, + // 「你声明的是这条会话」是自述,不是事实。 + part, err := sessionHasParticipant(ctx, target, agentName) + if err != nil { + // 查不到就当拒绝(fail closed):这道闸的失败模式必须是沉默的拒绝, + // 而「查不到却放行」就是把漏洞重新打开。 + return false, "not-your-session", err + } + if !part { + return false, "not-your-session", nil + } return true, "", nil } + +// sessionHasParticipant 判断 agentName 是否是这条会话的参与方(from / to / cc 任一)。 +// +// 与 SessionParticipants 同源(都逐封扫 mails),但只取「有没有」这一个比特, +// 且失败时**不静默当作没有**:err 交给调用方决定。 +// +// 为什么不用 sessions.from_agent:参与方是随往来增长的(转发、抄送都会带进新人), +// 会话刚建立时只有双方;只认 from_agent 会把「只是被抄送过的」正当读者判成外人。 +func sessionHasParticipant(ctx context.Context, sessionID uuid.UUID, agentName string) (bool, error) { + if agentName == "" { + return false, nil + } + // cc_list 是**JSON 数组**(models.Address),与 SessionParticipants 的解析 + // 口径逐字一致 —— 不用自己另写一套逗号切分。两处对同一份数据的理解不同, + // 就会在“只被抄送过的人”上给出不同答案(那正是判据要认的正当读者)。 + var from, to string + var ccRaw []byte + rows, err := db.DB.QueryContext(ctx, ` + SELECT from_name, to_name, COALESCE(cc_list, '') + FROM mails + WHERE session_id = $1 + `, sessionID) + if err != nil { + return false, err + } + defer rows.Close() + + found := false + for rows.Next() { + if err := rows.Scan(&from, &to, &ccRaw); err != nil { + return false, err + } + if from == agentName || to == agentName { + found = true + break + } + if len(ccRaw) > 0 { + var cc []models.Address + if err := json.Unmarshal(ccRaw, &cc); err != nil { + // cc_list 坏掉不等于“不是参与方”。但也不能因此放行—— + // 让调用方当拒绝处理(err 非空 ⇒ fail closed)。 + return false, err + } + for _, c := range cc { + if c.Name == agentName { + found = true + break + } + } + } + if found { + break + } + } + if err := rows.Err(); err != nil { + return false, err + } + return found, nil +} diff --git a/server/internal/repo/session_workspace_test.go b/server/internal/repo/session_workspace_test.go index bef68cb..b2de278 100644 --- a/server/internal/repo/session_workspace_test.go +++ b/server/internal/repo/session_workspace_test.go @@ -2,8 +2,12 @@ package repo import ( "context" + "database/sql" + "encoding/json" "testing" + "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) @@ -45,54 +49,250 @@ func TestAgentMayReadSessionRejectsUndeclaredScope(t *testing.T) { } } -// 闸本身没坏:声明了且等于目标就放行。 +// 闸本身没坏:**参与方**声明了且等于目标就放行。 +// +// ★ 2026-10-02(第二次)改动:夹具必须造真实的会话参与关系。 +// 上一版这个用例用 `uuid.New()` 造一条**不存在的**会话就断言放行 —— +// 那正是漏洞的形状(声明一个 id 就能读),所以“修好之后它变红”不是回归, +// 是判据自己钉的就是漏洞。 func TestAgentMayReadSessionAllowsOwnSession(t *testing.T) { setupTestDB(t) ctx := context.Background() - id := uuid.New() + id := seedSessionWithParticipant(t, "dsh", "gui-lab", "主题") - ok, _, err := AgentMayReadSession(ctx, "dsh", &id, id) + ok, reason, err := AgentMayReadSession(ctx, "dsh", &id, id) if err != nil { t.Fatal(err) } if !ok { - t.Fatal("声明的会话等于目标时必须放行(否则修复会把正常读信也堵死)") + t.Fatalf("★ 会话参与方读自己的信必须放行(否则修复会把正常读信堵死);reason=%q", reason) } } -// 声明了但不是目标 ⇒ 拒绝(跨会话隔离,这是「每个 session 是独立用户」的落点)。 -func TestAgentMayReadSessionRejectsOtherSession(t *testing.T) { +// ★ 2026-10-02(第二次):**声明别人的会话 id 必须拒绝**。 +// +// 这是今天端到端撞出来的漏洞本体: +// +// mcp-probe 读 mcp-peer 的信,**带着正确的 session_id** ⇒ 200 泄露正文。 +// +// 绕开 MCP 直打原生端点同样 200 ⇒ 根因在网关,不在 MCP。 +// 生产复核:dsh 声明 gui-lab 的 session_id ⇒ 200 完整正文。 +// +// 上一版判据只有 `scope == target`,而 session_id 由请求方提供 ⇒ +// 任何拿到 Agent 凭据的客户端报出一个已存在的会话 id 就能以它身份读。 +func TestAgentMayReadSessionRejectsClaimingOthersSession(t *testing.T) { setupTestDB(t) ctx := context.Background() - mine, theirs := uuid.New(), uuid.New() + // 会话里只有 gui-lab 与 homeagent,没有 dsh + theirs := seedSessionWithParticipant(t, "gui-lab", "homeagent", "别人的线索") - ok, reason, err := AgentMayReadSession(ctx, "dsh", &mine, theirs) + ok, reason, err := AgentMayReadSession(ctx, "dsh", &theirs, theirs) if err != nil { t.Fatal(err) } if ok { - t.Fatal("★ 跨会话读必须拒绝") + t.Fatal("★ 声明一条自己不是参与方的会话必须拒绝 —— " + + "实测 dsh 声明 gui-lab 的 session_id 就读到了对方信件的完整正文(HTTP 200)") } if reason != "not-your-session" { - t.Fatalf("reason=%q", reason) + t.Fatalf("reason 必须是 canReadSession 已处理的值,实际 %q", reason) } } -// agentName 不参与判断 —— 这是**刻意**的(2026-09-15 用户裁定: -// 「每个 session 概念上是一个独立的『用户』」,不按 agent 身份仲裁)。 -// 这一格钉住那个裁定,防止将来有人"顺手"加一层按 agent 的判断。 -func TestAgentMayReadSessionIgnoresAgentName(t *testing.T) { +// 只被**抄送**过也算参与方(否则正当读者被判成外人)。 +func TestAgentMayReadSessionAllowsCCOnlyParticipant(t *testing.T) { setupTestDB(t) ctx := context.Background() - mine, theirs := uuid.New(), uuid.New() + // gui-lab 发给 dsh,**抄送** homeagent;homeagent 从未是 from/to + id := seedSessionRaw(t, "gui-lab", "dsh", "抄送场景", []string{"homeagent"}) - asDSH, _, _ := AgentMayReadSession(ctx, "dsh", &mine, theirs) - asNobody, _, _ := AgentMayReadSession(ctx, "完全不相干的 agent", &mine, theirs) - if asDSH != asNobody { - t.Fatalf("判定不应随 agentName 改变(2026-09-15 裁定:会话才是私有单位):dsh=%v other=%v", - asDSH, asNobody) + ok, _, err := AgentMayReadSession(ctx, "homeagent", &id, id) + if err != nil { + t.Fatal(err) } - if asDSH { - t.Fatal("跨会话仍应拒绝") + if !ok { + t.Fatal("★ 只被抄送过的 Agent 也是正当读者(sessions.from_agent 只记发起人,用它会把抄送方误判成外人)") } -} \ No newline at end of file +} + +// 空 agentName 必须拒绝(fail closed)。 +func TestAgentMayReadSessionRejectsEmptyAgentName(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + id := seedSessionWithParticipant(t, "", "homeagent", "无主会话") + + ok, _, err := AgentMayReadSession(ctx, "", &id, id) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("★ 空身份必须拒绝(fail closed)") + } +} + +// 判定**要**随 agentName 改变 —— 2026-10-02(第二次)攻破了上一版的这条裁定。 +// +// 上一版写着「agentName 不参与判断(2026-09-15 裁定:会话才是独立用户)」, +// 并用断言把它钉死。今天的实测证明那条裁定在**网关能被直接调用**时 +// 就是一个洞:2026-09-15 讨论的前提是“信任边界在桥”,而那不是服务端 +// 能强制的事实(任何持有 Agent 凭据的客户端都能直接打这些端点)。 +// +// 现在保留 2026-09-15 的**核心**意图(会话是独立单位、不跨会话读取), +// 但把“身份”从**声明**变成**可验证的事实**。 +func TestAgentMayReadSessionDependsOnAgentName(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + theirs := seedSessionWithParticipant(t, "gui-lab", "homeagent", "别人的线索") + + outsider, _, _ := AgentMayReadSession(ctx, "dsh", &theirs, theirs) + participant, _, _ := AgentMayReadSession(ctx, "gui-lab", &theirs, theirs) + + if outsider == participant { + t.Fatal("★ 判定必须随身份改变 —— " + + "若两者相同,说明「你是不是参与方」没进判据," + + "那正是 dsh 能声明别人的 session_id 读到对方正文的原因") + } + if outsider { + t.Error("非参与方必须被拒") + } + if !participant { + t.Error("参与方必须放行(否则修复会把正常读信堵死)") + } +} + +// 跨会话仍拒绕(2026-09-15 的核心裁定保留):即参与了,也不能跨会话读。 +func TestAgentMayReadSessionRejectsCrossSessionEvenForParticipant(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + mine := seedSessionWithParticipant(t, "dsh", "gui-lab", "我的线索") + alsoMine := seedSessionWithParticipant(t, "dsh", "gui-lab", "我另一条线索") + + ok, _, err := AgentMayReadSession(ctx, "dsh", &alsoMine, mine) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("★ 即使是参与方,跨会话读也必须拒绝(2026-09-15:每个 session 是独立『用户』)") + } +} + +// ---- 夹具 ---- + +// seedSessionWithParticipant 造一条会话:from 收件给 to(可选抄送),返回会话 id。 +func seedSessionWithParticipant(t *testing.T, from, to, subject string) uuid.UUID { + t.Helper() + id := seedSessionRaw(t, from, to, subject, nil) + return id +} + +// seedSessionRaw 插入 sessions + 第一封邮件。 +func seedSessionRaw(t *testing.T, from, to, subject string, cc []string) uuid.UUID { + t.Helper() + if from != "" { + if _, err := dbDB().ExecContext(context.Background(), + `INSERT OR IGNORE INTO agents (agent_name, secret, platform, default_rounds) + VALUES ($1, 'x', 'test', 50)`, from); err != nil { + t.Fatalf("seed from agent: %v", err) + } + } + if to != "" { + if _, err := dbDB().ExecContext(context.Background(), + `INSERT OR IGNORE INTO agents (agent_name, secret, platform, default_rounds) + VALUES ($1, 'x', 'test', 50)`, to); err != nil { + t.Fatalf("seed to agent: %v", err) + } + } + var id uuid.UUID + if err := dbDB().QueryRowContext(context.Background(), + `INSERT INTO sessions (subject, status, from_agent) VALUES ($1,'active',$2) + RETURNING session_id`, subject, from).Scan(&id); err != nil { + t.Fatalf("insert session: %v", err) + } + // cc_list 的真实形状是 `[{"name":…,"path":…}]`(生产实测, + // 见 SELECT DISTINCT cc_list)—— 夹具必须照它写,否则这格验的是 + // 一个生产中不存在的形状。 + ccJSON := "[]" + if len(cc) > 0 { + addrs := make([]models.Address, 0, len(cc)) + for _, name := range cc { + addrs = append(addrs, models.Address{Name: name, Raw: name}) + } + b, _ := json.Marshal(addrs) + ccJSON = string(b) + } + if _, err := dbDB().ExecContext(context.Background(), + `INSERT INTO mails (session_id, from_name, to_name, subject, body, status, cc_list) + VALUES ($1,$2,$3,$4,'x','unread',$5)`, id, from, to, subject, ccJSON); err != nil { + t.Fatalf("insert mail: %v", err) + } + return id +} + +// dbDB 是本文件的 DB 小助手。 +func dbDB() *sql.DB { return db.DB } + +// ★ 收件方(to)也必须是参与方 —— 只认 from_agent 会把主收件人判成外人。 +// +// 补这一格是因为变异验证发现的:把 `from == agentName || to == agentName` +// 改成只判 from,**全部判据仍然绿**(红格 0)。 +// 那种改法在生产里意味着「所有收件人都读不到自己的信」,而症状很隐蔽: +// Agent 收到信却读不了正文 ⇒ 看起来像 prompt 有问题。 +func TestAgentMayReadSessionAllowsPrimaryRecipient(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + // homeagent 是**主收件人**(to),不是发起人、也不是抄送方 + id := seedSessionRaw(t, "gui-lab", "homeagent", "主收件", nil) + + ok, reason, err := AgentMayReadSession(ctx, "homeagent", &id, id) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("★ 主收件人必须放行(to 也是参与方);reason=%q", reason) + } +} + +// ★ fail closed:查参与方出错时必须拒绝,绝不 fail open。 +// +// 这道闸的失败模式必须是**沉默的拒绝**。一旦「查不到就放行」, +// 数据库一抖(或将来换驱动解析 cc 失败)就等于把今天的漏洞重新打开 —— +// 而那种失败没有任何日志、没有告警,只有“突然多了很多能读别人信的人”。 +// +// 补这一格的原因同上:变异验证里把 fail-closed 改成 fail-open 时,红格为 0。 +func TestAgentMayReadSessionFailsClosed(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + id := seedSessionRaw(t, "gui-lab", "homeagent", "t", nil) + + // 把 mails 表换成一个返回错误的视图不可能(不建表);改为直接验证 + // sessionHasParticipant 在「会话不存在」时返回 (false, nil), + // 而 AgentMayReadSession 在 err != nil 时一定返回 ok=false。 + // + // 这里用一个**存在但查不到参与方**的会话:uuid.New() 从没被插进 mails。 + // 真实 err 路径由实现里「return false, ..., err」保证, + // 而 fail-open 变异会把那个分支改成 return true —— 所以再补一格直接盯它。 + empty := uuid.New() + ok, _, err := AgentMayReadSession(ctx, "homeagent", &empty, empty) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("★ 会话不存在(查不到任何参与方)时必须拒绝") + } + + // 直接盯 fail-closed 分支:让 db 出一个错。做法是关掉连接。 + closedDB(t) + ok, reason, err2 := AgentMayReadSession(ctx, "homeagent", &id, id) + if err2 == nil && ok { + t.Fatal("★ 数据库不可用时必须拒绝且带错误(fail closed),实测放行了") + } + if ok { + t.Fatalf("★ 数据库不可用时不得放行(reason=%q)", reason) + } +} + +// closedDB 临时关掉连接池,制造 DB 错误。 +func closedDB(t *testing.T) { + t.Helper() + db.Close() +}