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" ) /* `AgentMayReadSession` —— 决定「能不能读这条会话」的那道闸(2026-10-02 补判据)。 # 为什么它此前零覆盖 `grep -rln AgentMayReadSession --include=*_test.go` ⇒ **没有任何测试碰过它**。 一个决定安全边界的函数没有任何判据,于是 2026-09-15 那个 「未声明 scope 就放行」的迁移期妥协一直活着,直到今天被实测打出来。 # 那个妥协的代价(实测数字,见函数头注释) dsh 密钥 + 不带 session_id ⇒ 20/20 封别人的信全部 200(完整正文)。 带上 session_id ⇒ 闸是好的(带自己会话读别人的 = 403)。 ⇒ 缺口就是 `scope == nil` 那一个分支。 */ // ★ 核心:未声明 session_id 必须**拒绝**(旧语义是放行)。 func TestAgentMayReadSessionRejectsUndeclaredScope(t *testing.T) { setupTestDB(t) ctx := context.Background() target := uuid.New() ok, reason, err := AgentMayReadSession(ctx, "dsh", nil, target) if err != nil { t.Fatal(err) } if ok { t.Fatal("★ 未声明 session_id 必须拒绝 —— 实测旧语义下 dsh 不带 session_id " + "读到了 20/20 封别人的信(收件方 pi/homeagent/opencode,跨工作区),全部 200") } // reason 必须是 canReadSession 认识的既有值:新增 reason 而不同步改 // handler 的 switch,会让那个端点返回 500 而不是 403。 if reason != "not-your-session" { t.Fatalf("★ reason 必须是 canReadSession 已处理的 %q(否则 handler 落到 default 分支或 500),实际 %q", "not-your-session", reason) } } // 闸本身没坏:**参与方**声明了且等于目标就放行。 // // ★ 2026-10-02(第二次)改动:夹具必须造真实的会话参与关系。 // 上一版这个用例用 `uuid.New()` 造一条**不存在的**会话就断言放行 —— // 那正是漏洞的形状(声明一个 id 就能读),所以“修好之后它变红”不是回归, // 是判据自己钉的就是漏洞。 func TestAgentMayReadSessionAllowsOwnSession(t *testing.T) { setupTestDB(t) ctx := context.Background() id := seedSessionWithParticipant(t, "dsh", "gui-lab", "主题") ok, reason, err := AgentMayReadSession(ctx, "dsh", &id, id) if err != nil { t.Fatal(err) } if !ok { t.Fatalf("★ 会话参与方读自己的信必须放行(否则修复会把正常读信堵死);reason=%q", reason) } } // ★ 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() // 会话里只有 gui-lab 与 homeagent,没有 dsh theirs := seedSessionWithParticipant(t, "gui-lab", "homeagent", "别人的线索") ok, reason, err := AgentMayReadSession(ctx, "dsh", &theirs, theirs) if err != nil { t.Fatal(err) } if ok { t.Fatal("★ 声明一条自己不是参与方的会话必须拒绝 —— " + "实测 dsh 声明 gui-lab 的 session_id 就读到了对方信件的完整正文(HTTP 200)") } if reason != "not-your-session" { t.Fatalf("reason 必须是 canReadSession 已处理的值,实际 %q", reason) } } // 只被**抄送**过也算参与方(否则正当读者被判成外人)。 func TestAgentMayReadSessionAllowsCCOnlyParticipant(t *testing.T) { setupTestDB(t) ctx := context.Background() // gui-lab 发给 dsh,**抄送** homeagent;homeagent 从未是 from/to id := seedSessionRaw(t, "gui-lab", "dsh", "抄送场景", []string{"homeagent"}) ok, _, err := AgentMayReadSession(ctx, "homeagent", &id, id) if err != nil { t.Fatal(err) } if !ok { t.Fatal("★ 只被抄送过的 Agent 也是正当读者(sessions.from_agent 只记发起人,用它会把抄送方误判成外人)") } } // 空 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() }