# 漏洞
`POST /permission/request` 的 `to` 字段由 Agent 自由填写,服务端只检查
「这个名字是不是一个合法的人类用户」:
decider := req.To
if isHuman, _ := repo.IsHumanUser(ctx, decider); !isHuman { …回落… }
于是任何 Agent 都能把「是否允许执行 bash」这类危险操作的审批丢给**任意一个
与这条任务无关的人**(例如管理员)。被点名的人看到一封没有上下文的待办,
只能凭猜点头或拒绝。
这跟同一份代码里的另一段注释直接冲突。那段在论证为什么不把权限转给管理员:
管理员对这条 Agent 链的上下文一无所知,既不知道这个 bash 命令在做什么,
也不知道拒绝后 Agent 该怎么绕过去。
这个理由同样适用于「Agent 自己点名一个无关的人」—— 而且更弱:至少管理员还能
查日志,一个随机被点名的用户连从哪查都不知道。两处都指向同一条规则:
**权限应当追溯到最初分配任务的人**,也就是这条线索上的人。
这是静态审计发现的四项之一。当时三桥实测都不传 `to`,所以是潜在面而非活跃
漏洞 —— 但 `to` 是公开的 Agent API 字段,第三方插件照着文档填就会踩上。
# 修法
新增 `repo.IsHumanOnSessionThread(ctx, sessionID, name)`:人类身份 **且**
(会话 owner 或在这条会话的某封邮件里出现过)。
两个来源缺一不可,各有实测场景:
- **只要参与方**会漏掉「会话由 Agent 建立、owner 由平台指派」的会话 ——
那种 owner 可能一封邮件都没收发过,只看邮件会把合法 owner 判成外人,
于是每次审批都回落到线索上随便一个人类。
- **只要 owner** 会漏掉「人在别人的会话里被抄送进来说了话」这种正常协作。
采信与否的处置是**丢弃提示而不是报错**:`to` 只是一个偏好,丢弃后常规解析仍会
给出一个合法人类(owner 或线索上最近的人),实在没有就是既有的 409 —— 无论哪条
分支,都不会把审批送到错的人手上。硬失败则会让 Agent 一次乐观的提示断掉整个
任务,而它并没有做错什么。因为丢弃是静默的,所以**必须留下日志**:
[permission] 忽略不属于本线索的决策人 "jianf"(会话 …, 由 pi 指定)—— 改走常规解析
# 测试
补了这条路径此前**完全缺失**的两层覆盖(审计发现:决策路径
RequestPermission/DecidePermission/ListPendingPermissions 都没有测试):
- `repo/threadhuman_test.go`:白名单的六种输入(线索上发信/收信的人类、没发过
邮件的 owner、线索外的存在用户、不存在的名字、空串、抄送方),每条都写清
为什么期望这个结果。
- `handler/permission_request_test.go`:**真实 HTTP 层**跑 `RequestPermission`,
断言响应里的 decider 与库里那封权限邮件的 to_name。repo 层 helper 正确但
handler 漏调一次,漏洞就会回来,所以必须有端到端这一层。含纯 Agent 链的
fail-closed 断言(不得退回管理员)。
# 验证
- `go test ./... -count=1` 全绿;`go vet` 干净;`gofmt` 差异行数与改动前完全
相同(8 行,既有的一处空行)—— 即本次改动零新增格式问题
- 真机(workspace 档会话,owner=gui-lab,线索参与者 gui-lab+pi):
- `to=jianf`(线索外人类)→ decider=gui-lab,库中 to_name=gui-lab,
日志有忽略记录
- `to=gui-lab`(线索内)→ 采纳
- `to=pi`(Agent)→ 忽略,回落 owner
- 已部署(redeploy-gateway.sh 自动项全绿)
177 lines
5.9 KiB
Go
177 lines
5.9 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// RequestPermission 的决策人解析必须在 HTTP handler 层有覆盖。
|
||
//
|
||
// 过去 `to` 是 Agent 可自由填写的用户名,只要对应人类账号存在就直接采用。
|
||
// 这允许 Agent 把危险操作的审批丢给任意一个与任务无关的人;repo 层的参与方
|
||
// helper 即使正确,handler 漏调一次仍会把漏洞带回来。
|
||
func TestRequestPermissionRestrictsExplicitDeciderToThread(t *testing.T) {
|
||
setupPermissionHandlerDB(t)
|
||
ctx := context.Background()
|
||
|
||
mustPermissionUser(t, "owner")
|
||
mustPermissionUser(t, "reviewer")
|
||
mustPermissionUser(t, "outsider")
|
||
if err := repo.CreateOrUpdateAgent(ctx, "pi", "secret", "test", nil); err != nil {
|
||
t.Fatalf("注册 pi: %v", err)
|
||
}
|
||
|
||
ownerID := permissionUserID(t, "owner")
|
||
sid, err := repo.CreateSession(ctx, nil, "pi", "授权路由", "/tmp")
|
||
if err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
if err := repo.SetSessionOwner(ctx, sid, ownerID); err != nil {
|
||
t.Fatalf("设置 owner: %v", err)
|
||
}
|
||
if _, err := repo.CreateMail(ctx, sid, nil, "reviewer", "", "pi", "/tmp",
|
||
"任务", "请执行", nil); err != nil {
|
||
t.Fatalf("建线索邮件: %v", err)
|
||
}
|
||
|
||
t.Run("无关人类被忽略并回落 owner", func(t *testing.T) {
|
||
resp := requestPermission(t, sid, "outsider", "是否允许执行 bash?")
|
||
if resp.Code != http.StatusOK {
|
||
t.Fatalf("HTTP %d: %s", resp.Code, resp.Body.String())
|
||
}
|
||
var body map[string]any
|
||
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("解析响应: %v", err)
|
||
}
|
||
if got := body["decider"]; got != "owner" {
|
||
t.Fatalf("无关人类不应收到审批,decider=%v,期望 owner", got)
|
||
}
|
||
assertPermissionRecipient(t, body["mail_id"].(string), "owner")
|
||
})
|
||
|
||
t.Run("线索上的人类可被显式指定", func(t *testing.T) {
|
||
resp := requestPermission(t, sid, "reviewer", "是否允许写文件?")
|
||
if resp.Code != http.StatusOK {
|
||
t.Fatalf("HTTP %d: %s", resp.Code, resp.Body.String())
|
||
}
|
||
var body map[string]any
|
||
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("解析响应: %v", err)
|
||
}
|
||
if got := body["decider"]; got != "reviewer" {
|
||
t.Fatalf("线索上的人类应保持显式优先级,decider=%v", got)
|
||
}
|
||
assertPermissionRecipient(t, body["mail_id"].(string), "reviewer")
|
||
})
|
||
|
||
t.Run("线索上的 Agent 仍不能成为决策人", func(t *testing.T) {
|
||
resp := requestPermission(t, sid, "pi", "是否允许编辑?")
|
||
if resp.Code != http.StatusOK {
|
||
t.Fatalf("HTTP %d: %s", resp.Code, resp.Body.String())
|
||
}
|
||
var body map[string]any
|
||
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("解析响应: %v", err)
|
||
}
|
||
if got := body["decider"]; got != "owner" {
|
||
t.Fatalf("Agent 不能在 WebUI 决策,应回落 owner,实际 %v", got)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestRequestPermissionRejectsAllAgentThread(t *testing.T) {
|
||
setupPermissionHandlerDB(t)
|
||
ctx := context.Background()
|
||
for _, name := range []string{"pi", "dsh"} {
|
||
if err := repo.CreateOrUpdateAgent(ctx, name, "secret", "test", nil); err != nil {
|
||
t.Fatalf("注册 %s: %v", name, err)
|
||
}
|
||
}
|
||
sid, err := repo.CreateSession(ctx, nil, "pi", "纯 Agent 链", "/tmp")
|
||
if err != nil {
|
||
t.Fatalf("建会话: %v", err)
|
||
}
|
||
if _, err := repo.CreateMail(ctx, sid, nil, "dsh", "", "pi", "/tmp",
|
||
"任务", "请执行", nil); err != nil {
|
||
t.Fatalf("建线索邮件: %v", err)
|
||
}
|
||
|
||
resp := requestPermission(t, sid, "human", "是否允许执行 bash?")
|
||
if resp.Code != http.StatusConflict {
|
||
t.Fatalf("纯 Agent 链必须 fail closed,HTTP %d: %s", resp.Code, resp.Body.String())
|
||
}
|
||
if bytes.Contains(resp.Body.Bytes(), []byte(`"decider":"admin"`)) {
|
||
t.Fatalf("不得退回无关管理员: %s", resp.Body.String())
|
||
}
|
||
}
|
||
|
||
func setupPermissionHandlerDB(t *testing.T) {
|
||
t.Helper()
|
||
db.Close()
|
||
path := filepath.Join(t.TempDir(), "permission-handler.db")
|
||
if err := db.Connect(context.Background(), "sqlite://"+path); err != nil {
|
||
t.Fatalf("连接测试库: %v", err)
|
||
}
|
||
if err := db.Migrate(context.Background()); err != nil {
|
||
t.Fatalf("迁移测试库: %v", err)
|
||
}
|
||
t.Cleanup(db.Close)
|
||
}
|
||
|
||
func mustPermissionUser(t *testing.T, username string) {
|
||
t.Helper()
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`INSERT INTO users (user_id, username, display_name, password_hash, role)
|
||
VALUES ($1, $2, $3, 'x', 'user')`, uuid.New(), username, username); err != nil {
|
||
t.Fatalf("建用户 %s: %v", username, err)
|
||
}
|
||
}
|
||
|
||
func permissionUserID(t *testing.T, username string) uuid.UUID {
|
||
t.Helper()
|
||
var id uuid.UUID
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT user_id FROM users WHERE username = $1`, username).Scan(&id); err != nil {
|
||
t.Fatalf("查用户 %s: %v", username, err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
func requestPermission(t *testing.T, sid uuid.UUID, to, question string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
payload, _ := json.Marshal(map[string]any{
|
||
"session_id": sid.String(),
|
||
"to": to,
|
||
"question": question,
|
||
"options": []string{"同意", "拒绝"},
|
||
})
|
||
req := httptest.NewRequest(http.MethodPost, "/api/v1/permission/request", bytes.NewReader(payload))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
ctx := context.WithValue(req.Context(), middleware.AgentNameKey, "pi")
|
||
resp := httptest.NewRecorder()
|
||
RequestPermission(resp, req.WithContext(ctx))
|
||
return resp
|
||
}
|
||
|
||
func assertPermissionRecipient(t *testing.T, mailID, want string) {
|
||
t.Helper()
|
||
var got string
|
||
if err := db.DB.QueryRowContext(context.Background(),
|
||
`SELECT to_name FROM mails WHERE mail_id = $1`, mailID).Scan(&got); err != nil {
|
||
t.Fatalf("查权限邮件 %s: %v", mailID, err)
|
||
}
|
||
if got != want {
|
||
t.Fatalf("权限邮件收件人=%q,期望 %q", got, want)
|
||
}
|
||
}
|