## 漏洞(亲自实测,不是读码推断)
用 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` 干净。
98 lines
3.3 KiB
Go
98 lines
3.3 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"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)
|
||
}
|
||
}
|
||
|
||
// 闸本身没坏:声明了且等于目标就放行。
|
||
func TestAgentMayReadSessionAllowsOwnSession(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
id := uuid.New()
|
||
|
||
ok, _, err := AgentMayReadSession(ctx, "dsh", &id, id)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !ok {
|
||
t.Fatal("声明的会话等于目标时必须放行(否则修复会把正常读信也堵死)")
|
||
}
|
||
}
|
||
|
||
// 声明了但不是目标 ⇒ 拒绝(跨会话隔离,这是「每个 session 是独立用户」的落点)。
|
||
func TestAgentMayReadSessionRejectsOtherSession(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
mine, theirs := uuid.New(), uuid.New()
|
||
|
||
ok, reason, err := AgentMayReadSession(ctx, "dsh", &mine, theirs)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ok {
|
||
t.Fatal("★ 跨会话读必须拒绝")
|
||
}
|
||
if reason != "not-your-session" {
|
||
t.Fatalf("reason=%q", reason)
|
||
}
|
||
}
|
||
|
||
// agentName 不参与判断 —— 这是**刻意**的(2026-09-15 用户裁定:
|
||
// 「每个 session 概念上是一个独立的『用户』」,不按 agent 身份仲裁)。
|
||
// 这一格钉住那个裁定,防止将来有人"顺手"加一层按 agent 的判断。
|
||
func TestAgentMayReadSessionIgnoresAgentName(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
mine, theirs := uuid.New(), uuid.New()
|
||
|
||
asDSH, _, _ := AgentMayReadSession(ctx, "dsh", &mine, theirs)
|
||
asNobody, _, _ := AgentMayReadSession(ctx, "完全不相干的 agent", &mine, theirs)
|
||
if asDSH != asNobody {
|
||
t.Fatalf("判定不应随 agentName 改变(2026-09-15 裁定:会话才是私有单位):dsh=%v other=%v",
|
||
asDSH, asNobody)
|
||
}
|
||
if asDSH {
|
||
t.Fatal("跨会话仍应拒绝")
|
||
}
|
||
} |