## 漏洞(实测,生产可利用)
对照实验(同一会话、同一 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 包绿。
299 lines
11 KiB
Go
299 lines
11 KiB
Go
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()
|
||
}
|