fix(gateway): default-deny missing connect scopes

This commit is contained in:
Peter Steinberger
2026-02-09 21:32:51 -06:00
parent 27453f5a31
commit cfd112952e
5 changed files with 184 additions and 24 deletions

View File

@@ -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.

View File

@@ -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"),

View File

@@ -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<boolean
});
}
const openWs = async (port: number) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
const openWs = async (port: number, headers?: Record<string, string>) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined);
await new Promise<void>((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<ReturnType<typeof startGatewayServer>>;
@@ -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<string, unknown>;
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<string, unknown>;
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<void>((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();

View File

@@ -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",

View File

@@ -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<string, string> },
) {
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<ReturnType<typeof startGatewayServer>> | 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<void>((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
? {