mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 12:11:24 +00:00
refactor(gateway): split browser auth hardening paths
This commit is contained in:
155
src/gateway/server.auth.browser-hardening.test.ts
Normal file
155
src/gateway/server.auth.browser-hardening.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
import {
|
||||||
|
loadOrCreateDeviceIdentity,
|
||||||
|
publicKeyRawBase64UrlFromPem,
|
||||||
|
signDevicePayload,
|
||||||
|
} from "../infra/device-identity.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||||
|
import {
|
||||||
|
connectReq,
|
||||||
|
installGatewayTestHooks,
|
||||||
|
readConnectChallengeNonce,
|
||||||
|
testState,
|
||||||
|
trackConnectChallengeNonce,
|
||||||
|
withGatewayServer,
|
||||||
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
|
installGatewayTestHooks({ scope: "suite" });
|
||||||
|
|
||||||
|
const TEST_OPERATOR_CLIENT = {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "test",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
|
};
|
||||||
|
|
||||||
|
const originForPort = (port: number) => `http://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);
|
||||||
|
trackConnectChallengeNonce(ws);
|
||||||
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
return ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createSignedDevice(params: {
|
||||||
|
token: string;
|
||||||
|
scopes: string[];
|
||||||
|
clientId: string;
|
||||||
|
clientMode: string;
|
||||||
|
identityPath?: string;
|
||||||
|
nonce: string;
|
||||||
|
signedAtMs?: number;
|
||||||
|
}) {
|
||||||
|
const identity = params.identityPath
|
||||||
|
? loadOrCreateDeviceIdentity(params.identityPath)
|
||||||
|
: loadOrCreateDeviceIdentity();
|
||||||
|
const signedAtMs = params.signedAtMs ?? Date.now();
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: params.clientId,
|
||||||
|
clientMode: params.clientMode,
|
||||||
|
role: "operator",
|
||||||
|
scopes: params.scopes,
|
||||||
|
signedAtMs,
|
||||||
|
token: params.token,
|
||||||
|
nonce: params.nonce,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
device: {
|
||||||
|
id: identity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||||
|
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
nonce: params.nonce,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gateway auth browser hardening", () => {
|
||||||
|
test("rejects non-local browser origins for non-control-ui clients", async () => {
|
||||||
|
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||||
|
await withGatewayServer(async ({ port }) => {
|
||||||
|
const ws = await openWs(port, { origin: "https://attacker.example" });
|
||||||
|
try {
|
||||||
|
const res = await connectReq(ws, {
|
||||||
|
token: "secret",
|
||||||
|
client: TEST_OPERATOR_CLIENT,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message ?? "").toContain("origin not allowed");
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => {
|
||||||
|
testState.gatewayAuth = {
|
||||||
|
mode: "token",
|
||||||
|
token: "secret",
|
||||||
|
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true },
|
||||||
|
};
|
||||||
|
await withGatewayServer(async ({ port }) => {
|
||||||
|
const firstWs = await openWs(port, { origin: originForPort(port) });
|
||||||
|
try {
|
||||||
|
const first = await connectReq(firstWs, { token: "wrong" });
|
||||||
|
expect(first.ok).toBe(false);
|
||||||
|
expect(first.error?.message ?? "").not.toContain("retry later");
|
||||||
|
} finally {
|
||||||
|
firstWs.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondWs = await openWs(port, { origin: originForPort(port) });
|
||||||
|
try {
|
||||||
|
const second = await connectReq(secondWs, { token: "wrong" });
|
||||||
|
expect(second.ok).toBe(false);
|
||||||
|
expect(second.error?.message ?? "").toContain("retry later");
|
||||||
|
} finally {
|
||||||
|
secondWs.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
|
||||||
|
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
||||||
|
testState.gatewayAuth = { mode: "token", token: "secret" };
|
||||||
|
|
||||||
|
await withGatewayServer(async ({ port }) => {
|
||||||
|
const browserWs = await openWs(port, { origin: originForPort(port) });
|
||||||
|
try {
|
||||||
|
const nonce = await readConnectChallengeNonce(browserWs);
|
||||||
|
expect(typeof nonce).toBe("string");
|
||||||
|
const { identity, device } = await createSignedDevice({
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
clientId: TEST_OPERATOR_CLIENT.id,
|
||||||
|
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||||
|
identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`),
|
||||||
|
nonce: String(nonce ?? ""),
|
||||||
|
});
|
||||||
|
const res = await connectReq(browserWs, {
|
||||||
|
token: "secret",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
client: TEST_OPERATOR_CLIENT,
|
||||||
|
device,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message ?? "").toContain("pairing required");
|
||||||
|
|
||||||
|
const pairing = await listDevicePairing();
|
||||||
|
const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId);
|
||||||
|
expect(pending).toBeTruthy();
|
||||||
|
expect(pending?.silent).toBe(false);
|
||||||
|
} finally {
|
||||||
|
browserWs.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -672,17 +672,6 @@ describe("gateway server auth/connect", () => {
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects non-local browser origins for non-control-ui clients", async () => {
|
|
||||||
const ws = await openWs(port, { origin: "https://attacker.example" });
|
|
||||||
const res = await connectReq(ws, {
|
|
||||||
token: "secret",
|
|
||||||
client: TEST_OPERATOR_CLIENT,
|
|
||||||
});
|
|
||||||
expect(res.ok).toBe(false);
|
|
||||||
expect(res.error?.message ?? "").toContain("origin not allowed");
|
|
||||||
ws.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns control ui hint when token is missing", async () => {
|
test("returns control ui hint when token is missing", async () => {
|
||||||
const ws = await openWs(port, { origin: originForPort(port) });
|
const ws = await openWs(port, { origin: originForPort(port) });
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
@@ -712,27 +701,6 @@ describe("gateway server auth/connect", () => {
|
|||||||
);
|
);
|
||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => {
|
|
||||||
testState.gatewayAuth = {
|
|
||||||
mode: "token",
|
|
||||||
token: "secret",
|
|
||||||
rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true },
|
|
||||||
};
|
|
||||||
await withGatewayServer(async ({ port }) => {
|
|
||||||
const firstWs = await openWs(port, { origin: originForPort(port) });
|
|
||||||
const first = await connectReq(firstWs, { token: "wrong" });
|
|
||||||
expect(first.ok).toBe(false);
|
|
||||||
expect(first.error?.message ?? "").not.toContain("retry later");
|
|
||||||
firstWs.close();
|
|
||||||
|
|
||||||
const secondWs = await openWs(port, { origin: originForPort(port) });
|
|
||||||
const second = await connectReq(secondWs, { token: "wrong" });
|
|
||||||
expect(second.ok).toBe(false);
|
|
||||||
expect(second.error?.message ?? "").toContain("retry later");
|
|
||||||
secondWs.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("explicit none auth", () => {
|
describe("explicit none auth", () => {
|
||||||
@@ -1246,43 +1214,6 @@ describe("gateway server auth/connect", () => {
|
|||||||
restoreGatewayToken(prevToken);
|
restoreGatewayToken(prevToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
|
|
||||||
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
|
||||||
const { randomUUID } = await import("node:crypto");
|
|
||||||
const os = await import("node:os");
|
|
||||||
const path = await import("node:path");
|
|
||||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
const browserWs = await openWs(port, { origin: originForPort(port) });
|
|
||||||
const nonce = await readConnectChallengeNonce(browserWs);
|
|
||||||
const { identity, device } = await createSignedDevice({
|
|
||||||
token: "secret",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
clientId: TEST_OPERATOR_CLIENT.id,
|
|
||||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
|
||||||
identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`),
|
|
||||||
nonce,
|
|
||||||
});
|
|
||||||
const res = await connectReq(browserWs, {
|
|
||||||
token: "secret",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
client: TEST_OPERATOR_CLIENT,
|
|
||||||
device,
|
|
||||||
});
|
|
||||||
expect(res.ok).toBe(false);
|
|
||||||
expect(res.error?.message ?? "").toContain("pairing required");
|
|
||||||
|
|
||||||
const pairing = await listDevicePairing();
|
|
||||||
const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId);
|
|
||||||
expect(pending).toBeTruthy();
|
|
||||||
expect(pending?.silent).toBe(false);
|
|
||||||
|
|
||||||
browserWs.close();
|
|
||||||
await server.close();
|
|
||||||
restoreGatewayToken(prevToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("merges remote node/operator pairing requests for the same unpaired device", async () => {
|
test("merges remote node/operator pairing requests for the same unpaired device", async () => {
|
||||||
const { mkdtemp } = await import("node:fs/promises");
|
const { mkdtemp } = await import("node:fs/promises");
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ const logWsControl = log.child("ws");
|
|||||||
const gatewayRuntime = runtimeForLogger(log);
|
const gatewayRuntime = runtimeForLogger(log);
|
||||||
const canvasRuntime = runtimeForLogger(logCanvas);
|
const canvasRuntime = runtimeForLogger(logCanvas);
|
||||||
|
|
||||||
|
type AuthRateLimitConfig = Parameters<typeof createAuthRateLimiter>[0];
|
||||||
|
|
||||||
|
function createGatewayAuthRateLimiters(rateLimitConfig: AuthRateLimitConfig | undefined): {
|
||||||
|
rateLimiter?: AuthRateLimiter;
|
||||||
|
browserRateLimiter: AuthRateLimiter;
|
||||||
|
} {
|
||||||
|
const rateLimiter = rateLimitConfig ? createAuthRateLimiter(rateLimitConfig) : undefined;
|
||||||
|
// Browser-origin WS auth attempts always use loopback-non-exempt throttling.
|
||||||
|
const browserRateLimiter = createAuthRateLimiter({
|
||||||
|
...rateLimitConfig,
|
||||||
|
exemptLoopback: false,
|
||||||
|
});
|
||||||
|
return { rateLimiter, browserRateLimiter };
|
||||||
|
}
|
||||||
|
|
||||||
export type GatewayServer = {
|
export type GatewayServer = {
|
||||||
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
|
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -311,16 +326,10 @@ export async function startGatewayServer(
|
|||||||
let hooksConfig = runtimeConfig.hooksConfig;
|
let hooksConfig = runtimeConfig.hooksConfig;
|
||||||
const canvasHostEnabled = runtimeConfig.canvasHostEnabled;
|
const canvasHostEnabled = runtimeConfig.canvasHostEnabled;
|
||||||
|
|
||||||
// Create auth rate limiter only when explicitly configured.
|
// Create auth rate limiters used by connect/auth flows.
|
||||||
const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit;
|
const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit;
|
||||||
const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig
|
const { rateLimiter: authRateLimiter, browserRateLimiter: browserAuthRateLimiter } =
|
||||||
? createAuthRateLimiter(rateLimitConfig)
|
createGatewayAuthRateLimiters(rateLimitConfig);
|
||||||
: undefined;
|
|
||||||
// Always keep a browser-origin fallback limiter for WS auth attempts.
|
|
||||||
const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({
|
|
||||||
...rateLimitConfig,
|
|
||||||
exemptLoopback: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let controlUiRootState: ControlUiRootState | undefined;
|
let controlUiRootState: ControlUiRootState | undefined;
|
||||||
if (controlUiRootOverride) {
|
if (controlUiRootOverride) {
|
||||||
|
|||||||
@@ -83,6 +83,52 @@ import { isUnauthorizedRoleError, UnauthorizedFloodGuard } from "./unauthorized-
|
|||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
|
|
||||||
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
|
const DEVICE_SIGNATURE_SKEW_MS = 2 * 60 * 1000;
|
||||||
|
const BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP = "198.18.0.1";
|
||||||
|
|
||||||
|
type HandshakeBrowserSecurityContext = {
|
||||||
|
hasBrowserOriginHeader: boolean;
|
||||||
|
enforceOriginCheckForAnyClient: boolean;
|
||||||
|
rateLimitClientIp: string | undefined;
|
||||||
|
authRateLimiter?: AuthRateLimiter;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveHandshakeBrowserSecurityContext(params: {
|
||||||
|
requestOrigin?: string;
|
||||||
|
hasProxyHeaders: boolean;
|
||||||
|
clientIp: string | undefined;
|
||||||
|
rateLimiter?: AuthRateLimiter;
|
||||||
|
browserRateLimiter?: AuthRateLimiter;
|
||||||
|
}): HandshakeBrowserSecurityContext {
|
||||||
|
const hasBrowserOriginHeader = Boolean(
|
||||||
|
params.requestOrigin && params.requestOrigin.trim() !== "",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
hasBrowserOriginHeader,
|
||||||
|
enforceOriginCheckForAnyClient: hasBrowserOriginHeader && !params.hasProxyHeaders,
|
||||||
|
rateLimitClientIp:
|
||||||
|
hasBrowserOriginHeader && isLoopbackAddress(params.clientIp)
|
||||||
|
? BROWSER_ORIGIN_LOOPBACK_RATE_LIMIT_IP
|
||||||
|
: params.clientIp,
|
||||||
|
authRateLimiter:
|
||||||
|
hasBrowserOriginHeader && params.browserRateLimiter
|
||||||
|
? params.browserRateLimiter
|
||||||
|
: params.rateLimiter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAllowSilentLocalPairing(params: {
|
||||||
|
isLocalClient: boolean;
|
||||||
|
hasBrowserOriginHeader: boolean;
|
||||||
|
isControlUi: boolean;
|
||||||
|
isWebchat: boolean;
|
||||||
|
reason: "not-paired" | "role-upgrade" | "scope-upgrade";
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
params.isLocalClient &&
|
||||||
|
(!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) &&
|
||||||
|
(params.reason === "not-paired" || params.reason === "scope-upgrade")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function attachGatewayWsMessageHandler(params: {
|
export function attachGatewayWsMessageHandler(params: {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
@@ -195,12 +241,19 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||||
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
|
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
|
||||||
const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== "");
|
const browserSecurity = resolveHandshakeBrowserSecurityContext({
|
||||||
const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders;
|
requestOrigin,
|
||||||
const browserRateLimitClientIp =
|
hasProxyHeaders,
|
||||||
hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp;
|
clientIp,
|
||||||
const authRateLimiter =
|
rateLimiter,
|
||||||
hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter;
|
browserRateLimiter,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
hasBrowserOriginHeader,
|
||||||
|
enforceOriginCheckForAnyClient,
|
||||||
|
rateLimitClientIp: browserRateLimitClientIp,
|
||||||
|
authRateLimiter,
|
||||||
|
} = browserSecurity;
|
||||||
|
|
||||||
socket.on("message", async (data) => {
|
socket.on("message", async (data) => {
|
||||||
if (isClosed()) {
|
if (isClosed()) {
|
||||||
@@ -338,7 +391,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||||
const isWebchat = isWebchatConnect(connectParams);
|
const isWebchat = isWebchatConnect(connectParams);
|
||||||
if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) {
|
if (enforceOriginCheckForAnyClient || isControlUi || isWebchat) {
|
||||||
const originCheck = checkBrowserOrigin({
|
const originCheck = checkBrowserOrigin({
|
||||||
requestHost,
|
requestHost,
|
||||||
origin: requestOrigin,
|
origin: requestOrigin,
|
||||||
@@ -622,10 +675,13 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
const requirePairing = async (
|
const requirePairing = async (
|
||||||
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
|
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
|
||||||
) => {
|
) => {
|
||||||
const allowSilentLocalPairing =
|
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
|
||||||
isLocalClient &&
|
isLocalClient,
|
||||||
(!hasBrowserOriginHeader || isControlUi || isWebchat) &&
|
hasBrowserOriginHeader,
|
||||||
(reason === "not-paired" || reason === "scope-upgrade");
|
isControlUi,
|
||||||
|
isWebchat,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
publicKey: devicePublicKey,
|
publicKey: devicePublicKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user