From cfd112952eefbb77f18cbd7b66cef44455941bed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 9 Feb 2026 21:32:51 -0600 Subject: [PATCH] fix(gateway): default-deny missing connect scopes --- CHANGELOG.md | 1 + .../OpenClawMacCLI/WizardCommand.swift | 3 +- src/gateway/server.auth.e2e.test.ts | 170 +++++++++++++++++- .../server/ws-connection/message-handler.ts | 13 +- src/gateway/test-helpers.server.ts | 21 ++- 5 files changed, 184 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 008ea5db98..8660c88ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials. - Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras. - Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc. +- Security/Gateway: default-deny missing connect `scopes` (no implicit `operator.admin`). - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. - Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr. - Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow. diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 9932b4a15b..898a8a31cf 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -250,7 +250,8 @@ actor GatewayWizardClient { let clientId = "openclaw-macos" let clientMode = "ui" let role = "operator" - let scopes: [String] = [] + // Explicit scopes; gateway no longer defaults empty scopes to admin. + let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] let client: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(clientId), "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 691522516b..36bd8de840 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; @@ -9,6 +9,7 @@ import { getFreePort, installGatewayTestHooks, onceMessage, + rpcReq, startGatewayServer, startServerWithClient, testTailscaleWhois, @@ -30,8 +31,8 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); +const openWs = async (port: number, headers?: Record) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined); await new Promise((resolve) => ws.once("open", resolve)); return ws; }; @@ -39,6 +40,7 @@ const openWs = async (port: number) => { const openTailscaleWs = async (port: number) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { + origin: "https://gateway.tailnet.ts.net", "x-forwarded-for": "100.64.0.1", "x-forwarded-proto": "https", "x-forwarded-host": "gateway.tailnet.ts.net", @@ -50,6 +52,8 @@ const openTailscaleWs = async (port: number) => { return ws; }; +const originForPort = (port: number) => `http://127.0.0.1:${port}`; + describe("gateway server auth/connect", () => { describe("default auth (token)", () => { let server: Awaited>; @@ -101,6 +105,147 @@ describe("gateway server auth/connect", () => { ws.close(); }); + test("does not grant admin when scopes are empty", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { scopes: [] }); + expect(res.ok).toBe(true); + + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(false); + expect(health.error?.message).toContain("missing scope"); + + ws.close(); + }); + + test("does not grant admin when scopes are omitted", async () => { + const ws = await openWs(port); + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? undefined) + : process.env.OPENCLAW_GATEWAY_TOKEN; + expect(typeof token).toBe("string"); + + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: [], + signedAtMs, + token: token ?? null, + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + + ws.send( + JSON.stringify({ + type: "req", + id: "c-no-scopes", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, + }, + caps: [], + role: "operator", + auth: token ? { token } : undefined, + device, + }, + }), + ); + const connectRes = await onceMessage<{ ok: boolean }>(ws, (o) => { + if (!o || typeof o !== "object" || Array.isArray(o)) { + return false; + } + const rec = o as Record; + return rec.type === "res" && rec.id === "c-no-scopes"; + }); + expect(connectRes.ok).toBe(true); + + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(false); + expect(health.error?.message).toContain("missing scope"); + + ws.close(); + }); + + test("rejects device signature when scopes are omitted but signed with admin", async () => { + const ws = await openWs(port); + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? undefined) + : process.env.OPENCLAW_GATEWAY_TOKEN; + expect(typeof token).toBe("string"); + + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + role: "operator", + scopes: ["operator.admin"], + signedAtMs, + token: token ?? null, + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + + ws.send( + JSON.stringify({ + type: "req", + id: "c-no-scopes-signed-admin", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, + }, + caps: [], + role: "operator", + auth: token ? { token } : undefined, + device, + }, + }), + ); + const connectRes = await onceMessage<{ ok: boolean; error?: { message?: string } }>( + ws, + (o) => { + if (!o || typeof o !== "object" || Array.isArray(o)) { + return false; + } + const rec = o as Record; + return rec.type === "res" && rec.id === "c-no-scopes-signed-admin"; + }, + ); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain("device signature invalid"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + test("sends connect challenge on open", async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}`); const evtPromise = onceMessage<{ payload?: unknown }>( @@ -261,7 +406,7 @@ describe("gateway server auth/connect", () => { }); test("returns control ui hint when token is missing", async () => { - const ws = await openWs(port); + const ws = await openWs(port, { origin: originForPort(port) }); const res = await connectReq(ws, { skipDefaultAuth: true, client: { @@ -277,7 +422,7 @@ describe("gateway server auth/connect", () => { }); test("rejects control ui without device identity by default", async () => { - const ws = await openWs(port); + const ws = await openWs(port, { origin: originForPort(port) }); const res = await connectReq(ws, { token: "secret", device: null, @@ -334,7 +479,9 @@ describe("gateway server auth/connect", () => { test("allows control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; - const { server, ws, prevToken } = await startServerWithClient("secret"); + const { server, ws, prevToken } = await startServerWithClient("secret", { + wsHeaders: { origin: "http://127.0.0.1" }, + }); const res = await connectReq(ws, { token: "secret", device: null, @@ -370,7 +517,10 @@ describe("gateway server auth/connect", () => { const port = await getFreePort(); const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { "x-forwarded-for": "203.0.113.10" }, + headers: { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + }, }); const challengePromise = onceMessage<{ payload?: unknown }>( ws, @@ -383,13 +533,14 @@ describe("gateway server auth/connect", () => { const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = await import("../infra/device-identity.js"); const identity = loadOrCreateDeviceIdentity(); + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const signedAtMs = Date.now(); const payload = buildDeviceAuthPayload({ deviceId: identity.deviceId, clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, role: "operator", - scopes: [], + scopes, signedAtMs, token: "secret", nonce: String(nonce), @@ -403,6 +554,7 @@ describe("gateway server auth/connect", () => { }; const res = await connectReq(ws, { token: "secret", + scopes, device, client: { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, @@ -428,7 +580,7 @@ describe("gateway server auth/connect", () => { process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; const port = await getFreePort(); const server = await startGatewayServer(port); - const ws = await openWs(port); + const ws = await openWs(port, { origin: originForPort(port) }); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = await import("../infra/device-identity.js"); const identity = loadOrCreateDeviceIdentity(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 89bd9531f7..19eec9b1be 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -356,13 +356,8 @@ export function attachGatewayWsMessageHandler(params: { close(1008, "invalid role"); return; } - const requestedScopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : []; - const scopes = - requestedScopes.length > 0 - ? requestedScopes - : role === "operator" - ? ["operator.admin"] - : []; + // Default-deny: scopes must be explicit. Empty/missing scopes means no permissions. + const scopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : []; connectParams.role = role; connectParams.scopes = scopes; @@ -586,7 +581,7 @@ export function attachGatewayWsMessageHandler(params: { clientId: connectParams.client.id, clientMode: connectParams.client.mode, role, - scopes: requestedScopes, + scopes, signedAtMs: signedAt, token: connectParams.auth?.token ?? null, nonce: providedNonce || undefined, @@ -600,7 +595,7 @@ export function attachGatewayWsMessageHandler(params: { clientId: connectParams.client.id, clientMode: connectParams.client.mode, role, - scopes: requestedScopes, + scopes, signedAtMs: signedAt, token: connectParams.auth?.token ?? null, version: "v1", diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 6fb436bb9c..f274776486 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -290,7 +290,11 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio return await mod.startGatewayServer(port, resolvedOpts); } -export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) { +export async function startServerWithClient( + token?: string, + opts?: GatewayServerOptions & { wsHeaders?: Record }, +) { + const { wsHeaders, ...gatewayOpts } = opts ?? {}; let port = await getFreePort(); const prev = process.env.OPENCLAW_GATEWAY_TOKEN; if (typeof token === "string") { @@ -310,7 +314,7 @@ export async function startServerWithClient(token?: string, opts?: GatewayServer let server: Awaited> | null = null; for (let attempt = 0; attempt < 10; attempt++) { try { - server = await startGatewayServer(port, opts); + server = await startGatewayServer(port, gatewayOpts); break; } catch (err) { const code = (err as { cause?: { code?: string } }).cause?.code; @@ -324,7 +328,10 @@ export async function startServerWithClient(token?: string, opts?: GatewayServer throw new Error("failed to start gateway server after retries"); } - const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const ws = new WebSocket( + `ws://127.0.0.1:${port}`, + wsHeaders ? { headers: wsHeaders } : undefined, + ); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); const cleanup = () => { @@ -415,7 +422,11 @@ export async function connectReq( : process.env.OPENCLAW_GATEWAY_PASSWORD; const token = opts?.token ?? defaultToken; const password = opts?.password ?? defaultPassword; - const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : []; + const requestedScopes = Array.isArray(opts?.scopes) + ? opts.scopes + : role === "operator" + ? ["operator.admin"] + : []; const device = (() => { if (opts?.device === null) { return undefined; @@ -455,7 +466,7 @@ export async function connectReq( commands: opts?.commands ?? [], permissions: opts?.permissions ?? undefined, role, - scopes: opts?.scopes, + scopes: requestedScopes, auth: token || password ? {