From 5fa7d31d87b01db634b11787128cf3571a81dca1 Mon Sep 17 00:00:00 2001 From: nzhu Date: Thu, 29 Jan 2026 10:48:54 +0800 Subject: [PATCH] gateway: add webchat role with strict method allowlist --- src/gateway/server-methods.ts | 16 +++++++ src/gateway/server.auth.e2e.test.ts | 43 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 5 ++- ui/src/ui/app-gateway.ts | 5 +++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 66492b976..ea92d4e72 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -34,6 +34,15 @@ const PAIRING_SCOPE = "operator.pairing"; const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]); const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event", "skills.bins"]); + +// Minimal method surface for public webchat clients. +// This is intentionally tiny; expand only when a concrete UI need is proven. +const WEBCHAT_ROLE_METHODS = new Set([ + "agent.identity.get", + "chat.history", + "chat.send", + "chat.abort", +]); const PAIRING_METHODS = new Set([ "node.pair.request", "node.pair.list", @@ -98,9 +107,16 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c if (role === "node") return null; return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } + if (role === "node") { return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } + + if (role === "webchat") { + if (WEBCHAT_ROLE_METHODS.has(method)) return null; + return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized method for role webchat: ${method}`); + } + if (role !== "operator") { return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`); } diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index ee700ead6..0bbf860c8 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -10,6 +10,7 @@ import { onceMessage, startGatewayServer, startServerWithClient, + rpcReq, testState, } from "./test-helpers.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -214,6 +215,48 @@ describe("gateway server auth/connect", () => { }); }); + describe("webchat role authz", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + + test("webchat role can connect but is restricted to chat methods", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { + token: "secret", + role: "webchat", + client: { + id: GATEWAY_CLIENT_NAMES.WEBCHAT_UI, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + + const deny = await rpcReq(ws, "sessions.list", { limit: 1 }); + expect(deny.ok).toBe(false); + + ws.close(); + }); + }); + describe("token auth", () => { let server: Awaited>; let port: number; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d1f6ae511..949629fee 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -333,7 +333,10 @@ export function attachGatewayWsMessageHandler(params: { } const roleRaw = connectParams.role ?? "operator"; - const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null; + const role = + roleRaw === "operator" || roleRaw === "node" || roleRaw === "webchat" + ? roleRaw + : null; if (!role) { setHandshakeState("failed"); setCloseCause("invalid-role", { diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 438aa5d62..25b8f4b8f 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -120,12 +120,17 @@ export function connectGateway(host: GatewayHost) { const clientName = host.tab === "public-chat" ? GATEWAY_CLIENT_NAMES.WEBCHAT_UI : "moltbot-control-ui"; + const role = host.tab === "public-chat" ? "webchat" : undefined; + host.client = new GatewayBrowserClient({ url: host.settings.gatewayUrl, token: host.settings.token.trim() ? host.settings.token : undefined, password: host.password.trim() ? host.password : undefined, clientName, mode: "webchat", + role, + // Leave scopes undefined for operator (gateway will default). For webchat we want no scopes. + scopes: role === "webchat" ? [] : undefined, onHello: (hello) => { host.connected = true; host.lastError = null;