feat(gateway,security): WS 上行帧角色门 + 握手回放仅 owner/admin(design/11 §7.2)
主人 2026-10-03 拍板"这一类要收紧"后,补齐 §7.1 下行门之外的两处同类缺口:
viewer 是只读监控档,却能通过 WS 拿到内容、甚至发起写入。
A. 握手 history 回放仅 owner/admin(ws_history.go)
连接建立时回放主会话最近 50 条消息正文,此前对任意角色照发 ⇒ viewer 一连上就
拿走主人对话内容。门放在 hub.replayHistory 的第一行 —— 它是唯一发出 history 帧的
函数,将来多一个调用者也绕不过去。
B. 上行 JSON 帧按角色授权(ws_route.go,fail-closed)
此前 route 完全没有角色检查。现在 route 的第一条语句即:非 owner/admin → 回定向
{type:"error",data:{code:"forbidden"}} 并 return,因此"不广播、不驱动模型、不落库、
不返回任何内容"是结构性的,而不是每个 handler 各自记得检查。写成默认拒而非黑名单 ——
chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch 以及
将来任何新帧都自动被拒。协议级帧(ping/pong/close)由 gorilla 在 ReadMessage 内处理,
不受影响(已实测:被拒后 ping→pong 正常、close 后服务端注销连接)。
被修掉的真实越权:viewer 发 chat 会真的驱动模型并把消息落库;subagent_ask /
task_output_query 的定向应答会绕过 §7.1 下行门,把子代理上下文/任务输出回给 viewer。
契约:Docs/api-client.md → v0.49(对 viewer 收紧;owner/admin 逐帧形状不变)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/...
ok;linux/amd64 + darwin/arm64 交叉编译绿;新测试 -count=3 稳定。
真 httptest WS + 真 JWT 逐帧实测:viewer 收不到 history 回放、上行一律定向 forbidden
且 runner 计数 = 0 / 落库 = 0;owner 侧 chat 驱动 runner = 1 且落库 2 条。
变异验证:① 去掉 A 的门 → viewer 收到 {"type":"history",...,"content":"主人的私密提问"},FAIL;
② 去掉 B 的门 → viewer 帧驱动 runner 1 次并落库 2 条、且直接收到 subagent_event / task_output,
FAIL。均已还原;且撤掉 B 时 A 的测试仍独立 PASS(两把门不互相代偿)。
遗留(如实登记):非 JSON 的上行字节不经过 route 的角色门(零副作用:不带 type、不触发
handler、不落库);admin 上行未单独测一格(与 owner 共用同一分支)。
This commit is contained in:
1 parent
bc0047b449
commit
770a83c3fb
5 files changed
+481
-12
No files matched your search
@@ -4,11 +4,45 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// route dispatches one inbound frame by its type.
|
||||
// wsForbiddenCode is the machine-readable code of the frame a non owner/admin
|
||||
// connection gets for any inbound instruction (design/11 §7.2 B). It rides the
|
||||
// file's existing error-frame shape — wsEvent{Type: "error", Data: map[string]any}
|
||||
// — so the client decodes it with the code it already knows, and distinguishes
|
||||
// 「你没有这个权限」 from a handler-level failure by the key alone.
|
||||
const wsForbiddenCode = "forbidden"
|
||||
|
||||
// route dispatches one decoded inbound (client→server) frame by its type.
|
||||
//
|
||||
// Role gate (design/11 §7.2 B, decided by the owner on 2026-10-03): a connection
|
||||
// that is not owner/admin may not send ANY in-app instruction. The check is the
|
||||
// first statement here and it denies by default — it is deliberately NOT a list of
|
||||
// forbidden types — so a frame type added by a later milestone is refused until
|
||||
// someone registers it on purpose. A viewer's /ws/chat connection is therefore a
|
||||
// read-only monitor: it follows the session state machine but can never drive the
|
||||
// model, cancel a turn, read a sub-agent's context or a task's output, or feed
|
||||
// voice input. Each refused frame is answered on that one connection with
|
||||
// {type:"error", data:{code:"forbidden"}}; nothing is broadcast, no handler runs,
|
||||
// so no model call is made and nothing is persisted.
|
||||
//
|
||||
// Widening this: a viewer-side inbound frame may be allowed only after it is given
|
||||
// an opening and registered in design/11 §7.2 (「必须先在设计里给它开口并登记白名单」).
|
||||
// The blanket deny below is what makes forgetting that step safe, so allowing one
|
||||
// means replacing it with a registry, not deleting it.
|
||||
//
|
||||
// Protocol-level frames (ping/pong/close) never reach here — gorilla handles them
|
||||
// inside ReadMessage — so the gate cannot kill a connection. A frame that is not
|
||||
// JSON at all is answered by readPump before dispatch; it carries no type and can
|
||||
// trigger nothing, which is why it keeps its parsing-level answer.
|
||||
func (c *wsConn) route(in wsInbound) {
|
||||
if !ownerAdminClaims(c.claims) {
|
||||
c.h.log.Debug("ws inbound rejected", "type", in.Type, "role", claimRole(c.claims))
|
||||
c.enqueueJSON(wsEvent{Type: "error", Data: map[string]any{"code": wsForbiddenCode}})
|
||||
return
|
||||
}
|
||||
switch in.Type {
|
||||
case "mode_switch":
|
||||
c.publishPublic("ws.mode_switch", rawData(in.Data))
|
||||
@@ -110,3 +144,13 @@ func (c *wsConn) handleTaskOutputQuery(in wsInbound) {
|
||||
"status": string(ev.Status),
|
||||
}})
|
||||
}
|
||||
|
||||
// claimRole labels a connection's role for the inbound-rejection log record. A
|
||||
// connection with nil claims (the fail-closed bare connection a unit test builds)
|
||||
// logs as "none" instead of dereferencing the nil pointer.
|
||||
func claimRole(claims *auth.Claims) string {
|
||||
if claims == nil {
|
||||
return "none"
|
||||
}
|
||||
return string(claims.Role)
|
||||
}
|
||||
Reference in new issue
Block a user