## 漏洞(实测,生产可利用)
对照实验(同一会话、同一 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 包绿。
255 lines
12 KiB
Go
255 lines
12 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// SessionWorkspaceOf 返回**这条会话**的工作目录(`sessions.workspace`)。
|
||
//
|
||
// # 为什么需要它
|
||
//
|
||
// 邮件的 `to_workspace` 是插件唯一能知道「这个任务该在哪个目录干活」的入口,
|
||
// 但它取的是**地址里的 path 位**。而 Agent 之间的回信、以及人在对话页点回复时,
|
||
// 地址里通常没有 path 位 —— 平台自己下发的 `reply_address` 就是这个形状。
|
||
//
|
||
// 空着传下去的后果是可观测的:插件只能自己拼一个临时目录,于是**每封邮件落在
|
||
// 一个不同的空目录里**;DSH / opencode 按 cwd 给会话分组,界面上就成了「每处理
|
||
// 一封邮件就多出一条未分组会话」,而模型在空目录里什么项目文件也看不到。
|
||
//
|
||
// 会话的 workspace 才是权威来源(见 `models.SessionWorkspace` 的注释):
|
||
// 回信本来就是回给**那条会话**的,而那条会话知道自己属于哪个项目。
|
||
//
|
||
// 读不到时返回空串而不是报错:投递路径不能因为一次查询失败就丢掉工作目录信息,
|
||
// 但也不能凭空编一个 —— 空串的语义就是「不知道」,由调用方决定怎么退化。
|
||
func SessionWorkspaceOf(ctx context.Context, id uuid.UUID) string {
|
||
var ws string
|
||
err := db.DB.QueryRowContext(ctx,
|
||
`SELECT COALESCE(workspace, '') FROM sessions WHERE session_id = $1`, id).Scan(&ws)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return ws
|
||
}
|
||
|
||
// AgentMayReadSession 判「以 `scope` 为当前会话的 Agent 能不能读 `target`」。
|
||
//
|
||
// ★ 2026-09-15 用户把模型说明白了(两次,第二次我看懂了):
|
||
//
|
||
// 「我要求的是不同 session 不同收件箱,而不是复杂的权限隔离……每个 session 是相对
|
||
// 独立的单位,他们不应该公用一个相对私有化的设施,相当于每个 session 概念上是一个
|
||
// 独立的『用户』」
|
||
//
|
||
// 那么这里就不该有任何"谁有资格读谁"的仲裁 —— **会话本身就是那个私有的单位**:
|
||
//
|
||
// target 必须就是 `scope`(我当前所在的那条会话)。
|
||
//
|
||
// 别的东西一概不看:不看工作区(那是 cwd/沙箱那条轴的事),也不看"这个 agent 参没参与过"
|
||
// (那是我之前加的"相对私有化"设施 —— 它把 agent 当成一个跨越所有会话的人,
|
||
// 既制造了越界读(同工作区里能读别的会话),又挡住了正当读者(发起者读不到自己发起的
|
||
// 会话,zcode 因此推断出"那五位 agent 不存在")。会话是独立单位,它不需要被一个
|
||
// 更高层的身份来"授权"。
|
||
//
|
||
// 信任边界在**桥**:`session_id` 由 worker 闭包注入(模型改不了),桥是我们的可信组件。
|
||
// 这与「一个邮件客户端替它持有的每个账号收发信」是同一种信任:声明自己是哪条会话,
|
||
// 就以那条会话的身份行事。
|
||
//
|
||
// ★★ 2026-10-02:未声明 scope **不再放行**(2026-09-15 那句「保持放行」到此作废)。
|
||
//
|
||
// # 那个妥协的代价(实测,不是推演)
|
||
//
|
||
// 用 dsh 的密钥、不带任何 session_id,逐个 GET `/api/v1/agent/mail/{id}`:
|
||
//
|
||
// 20 封别人的信(收件方 pi / homeagent / opencode,跨 /home/program/TrueAgent
|
||
// 等不同工作区)⇒ **20 封全部 200,拿到完整正文**,0 拒绝。
|
||
//
|
||
// 而带上 session_id 时闸是好的(对照实测):
|
||
//
|
||
// 带自己参与的 session_id 读别人的信 ⇒ 403
|
||
// 不带 session_id ⇒ 200(漏洞)
|
||
//
|
||
// ⇒ 缺口就是下面那个 `if scope == nil { return true }`。
|
||
//
|
||
// # 为什么「迁移期」已经结束(当初的理由已不成立)
|
||
//
|
||
// 当时的假设是「未接线的桥/脚本/浏览器会走这里,等接完就收口」。实测:
|
||
//
|
||
// [agent-scope] 警告日志累计 203 次,其中 **202 次来自四个桥自己**
|
||
// (homeagent 125 / dsh 40 / pi 37 / opencode 1),
|
||
// 集中在 `/api/v1/agent/mail/{id}`、读会话参与者、`/mail/{id}/forward`。
|
||
//
|
||
// 也就是说:不是「少数旧客户端还没接线」,而是**主力客户端在裸奔**,
|
||
// 而放行恰好把它们全部漏了过去。日志抓手已经完成了它的使命 ——
|
||
// 它精确地指出了「谁还没带」,而答案就是所有人。
|
||
//
|
||
// # 为什么不能靠「补齐调用方」来收口
|
||
//
|
||
// 那要同时改四个桥(其中 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) {
|
||
if scope == nil {
|
||
// ★ 2026-10-02(第一次):由 `return true`(旧语义放行)改为**拒绝**。
|
||
//
|
||
// 理由见函数头:实测 dsh 不带 session_id 能读 20/20 封别人的信,
|
||
// 而那 202 次裸奔里 202 次来自四个桥自己 ⇒ 「迁移期」早已结束。
|
||
//
|
||
// reason 用 "not-your-session" 而不是新造一个:canReadSession 那个分支的
|
||
// 文案是「你当前在会话 <self>」,而 self 为空时它已经能自然表达
|
||
// 「你还没声明自己在哪条会话」—— 不必新增文案就不会漏译。
|
||
// (新增 reason 的话,还要同步改 handler 的 switch,而漏改就是 500。)
|
||
return false, "not-your-session", nil
|
||
}
|
||
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
|
||
}
|