feat(gateway,security): WS 上行帧角色门 + 握手回放仅 owner/admin(design/11 §7.2)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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:
AskaEth committed 2026-10-03 22:02:16 +08:00
1 parent bc0047b449
commit 770a83c3fb
5 files changed
+481 -12

No files matched your search

+45 -1
View File
@@ -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)
}