Files
MailUI4Agents/server/internal/repo/session_workspace.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

255 lines
12 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"
"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
}