fix(安全)★★: 停用(status='disabled')不拦鉴权 —— 两条路径都能绕过

## 缺口怎么被发现的

给公网 MCP 接入做验证时注册了一个一次性探针身份
(`mcp-wan-probe`),收尾执行 `UPDATE agents SET status='disabled'`,
本以为凭证就此失效。实测:

    POST /api/v1/mcp(带该凭证)   → 200
    tools/call send_mail            → 已发送(真的发出去了)

## 根因

`SetAgentDisabled` 这个 API 存在、返回成功,但 **status 只在投递方向被检查**
(`EnsureAgentDeliverable`,repo.go:209)。两条鉴权路径都不查:

    VerifyAgent(ctx, name, secret)    选了 status 却从不判断   ← 缺口
    VerifyAgentKey(ctx, token)        拿到名字直接返回         ← 同一个缺口,更严重

第二条要紧:Bearer key_token 正是 `middleware/auth.go` 注释里标「推荐」的
路径,官方建议用它 —— 它因此成了绕过停用的最短路。

缺口形状是「接口存在、返回成功、但只做了一半」,比没有这个接口更危险:
运维会以为停用已经生效。实测语义完全反了 ——
**停用只挡住了「别人给它发信」,没挡住「它自己发信」**。

## 修法

停用 ⇒ **拿不到 Agent 身份** ⇒ `AgentAuth` 直接 401,后续 handler 根本不执行。
这比「拿到身份后在某个业务分支拒绝」强:后者会让列表类 API 仍泄露身份存在。

两个细节:

* 返回 `sql.ErrNoRows`(而非自定义错误)⇒ 与「凭证不存在」**不可区分**,
  否则可用该接口枚举出哪些名字是真实 Agent。判据里有专测这一格。
* status 检查放在 `wsJSON` 解析**之前** ⇒ 停用身份不会被解析出工作区,
  那会让调用方以为它还「活着」。

## 判据(4 格)

**其中「反向对照」这格最关键**:`status='online'/'active'/''` 必须照常通过。
没有它,一个「永远返回 ErrNoRows」的 `VerifyAgent` 也是全绿的 ——
而那会让**所有** agent 都登不上,是比原缺口更大的事故。

**变异验证**:

    撤掉 VerifyAgent 的 status 检查      → 3 格红 ✓
    撤掉 VerifyAgentKey 的 status 检查   → 1 格红 ✓

全量 14 包绿。
This commit is contained in:
2026-10-03 12:18:39 +08:00
parent 6a8e868d31
commit cf0ba1382c
3 changed files with 204 additions and 0 deletions

View File

@ -311,6 +311,19 @@ func ListAgents(ctx context.Context, statusFilter string) ([]models.Agent, error
return agents, nil
}
// VerifyAgent 校验 name+secret 并返回该 Agent。
//
// ★ 2026-10-03 修:停用(status='disabled')原本**只在投递时**生效
// (见 EnsureAgentDeliverable),鉴权这条路径完全不查 —— 于是
// `SetAgentDisabled` 看起来在工作,实际只挡住了「别人给它发信」,
// 它自己照样能用凭证调任何 API:
//
// UPDATE agents SET status='disabled' WHERE agent_name='mcp-wan-probe'
// POST /api/v1/mcp(带该凭证)→ 200,send_mail 真的发出去了
//
// 停用的语义是**撤销该身份的一切权限**,不只是「别人不能寄给它」。
// 拿不到 Agent 身份 ⇒ AgentAuth 直接 401,后面所有 handler 都不会执行,
// 与其它凭证错误的处理完全一致(不泄露「这个身份存在但被停了」)。
func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error) {
var a models.Agent
var wsJSON []byte
@ -322,6 +335,11 @@ func VerifyAgent(ctx context.Context, name, secret string) (*models.Agent, error
if err != nil {
return nil, err
}
// 早于 wsJSON 的解析:停用身份不该被解析出工作区(那会让调用方
// 以为它还「活着」)。
if a.Status == "disabled" {
return nil, sql.ErrNoRows
}
if wsJSON != nil {
json.Unmarshal(wsJSON, &a.Workspaces)
}