## 漏洞(亲自实测,不是读码推断)
用 dsh 的密钥、不带任何 session_id,逐个 GET `/api/v1/agent/mail/{id}`:
20 封别人的信(收件方 pi / homeagent / opencode,分属
/home/program/TrueAgent 等不同工作区)⇒ **20 封全部 200,拿到完整正文**,0 拒绝。
对照(证明闸本身没坏,只有一个缺口):
带自己参与的 session_id 读别人的信 ⇒ 403 ← 闸有效
不带 session_id ⇒ 200 ← 漏洞
根因是 `AgentMayReadSession` 的一个分支:`if scope == nil { return true }`。
该函数 2026-09-15 的注释写明「首次接线前的旧语义(未声明 scope)保持放行」——
那是**迁移期妥协**,不是设计。
## 为什么「迁移期」已经结束(实测数据推翻了当初的假设)
当初假设「未接线的桥/脚本/浏览器会走这里,等接完就收口」。而
`[agent-scope]` 警告日志累计 203 次,按调用方拆开:
homeagent 125 / dsh 40 / pi 37 / opencode 1 / 其它 0
⇒ **202/203 来自四个桥自己**,集中在 `/api/v1/agent/mail/{id}`(pi 24 次)、
读会话参与者、`/mail/{id}/forward`。
不是「少数旧客户端没接线」,而是**主力客户端在裸奔**,而放行恰好把它们全漏过去。
日志抓手已完成使命:它精确指出了「谁还没带」,答案就是所有人。
## 为什么不能靠「补齐调用方」收口
要同时改四个桥(pi 的 `getMailSessionId` 有 `= () => ''` 的默认值,
忘注入就是静默空串 ⇒ 回到裸奔)。**默认放行与默认拒绝的差别就在这里:
前者的失败模式是沉默的。** 任何一处漏了 = 静默越权,且没有任何东西会红。
## 判据
`grep -rln AgentMayReadSession --include=*_test.go` ⇒ 修改前**零覆盖**。
一个决定安全边界的函数没有任何判据,这就是妥协能活到今天的原因。
新增 4 格:未声明须拒 / 声明且相等须放行 / 跨会话须拒 / 判定不随 agentName 变
(后者钉住 2026-09-15 裁定「每个 session 是独立『用户』」,防有人顺手加按 agent 的仲裁)。
两个变异都经得起:恢复放行、reason 改成 handler 不认识的值,各红一格。
reason 复用既有的 `not-your-session`:新增 reason 不同步改 handler 的 switch
就会把 403 变成 500;复用后 canReadSession 的 self 为空时,文案自然表达
「你还没声明自己在哪条会话」。
验证:13 包全绿 + `-race` 干净。
114 lines
5.6 KiB
Go
114 lines
5.6 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"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 分支。
|
||
func AgentMayReadSession(ctx context.Context, agentName string, scope *uuid.UUID, target uuid.UUID) (bool, string, error) {
|
||
_ = ctx
|
||
_ = agentName
|
||
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
|
||
}
|
||
return true, "", nil
|
||
}
|