mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(gateway): default-deny missing connect scopes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user