Files
MailUI4Agents/server/internal/repo/session_workspace_test.go
JianFeeeee 095213b981 fix(安全)★★: 声明别人的 session_id 就能读那封信 —— 补「参与方」判据
## 漏洞(实测,生产可利用)

对照实验(同一会话、同一 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 包绿。
2026-10-02 13:27:43 +08:00

299 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}