Files
MailUI4Agents/server/internal/repo/session_workspace_test.go
JianFeeeee 15e4fe9203 fix(安全): 未声明 session_id 不再放行 —— 实测任意 agent 可读全部邮件正文
## 漏洞(亲自实测,不是读码推断)

用 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` 干净。
2026-10-02 00:34:13 +08:00

98 lines
3.3 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"
"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("跨会话仍应拒绝")
}
}