mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 23:28:27 +00:00
fix: harden extension relay auth token flow
This commit is contained in:
@@ -3,10 +3,15 @@ import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
|
|||||||
import { __test } from "./client-fetch.js";
|
import { __test } from "./client-fetch.js";
|
||||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||||
import { shouldRejectBrowserMutation } from "./csrf.js";
|
import { shouldRejectBrowserMutation } from "./csrf.js";
|
||||||
|
import {
|
||||||
|
ensureChromeExtensionRelayServer,
|
||||||
|
stopChromeExtensionRelayServer,
|
||||||
|
} from "./extension-relay.js";
|
||||||
import { toBoolean } from "./routes/utils.js";
|
import { toBoolean } from "./routes/utils.js";
|
||||||
import type { BrowserServerState } from "./server-context.js";
|
import type { BrowserServerState } from "./server-context.js";
|
||||||
import { listKnownProfileNames } from "./server-context.js";
|
import { listKnownProfileNames } from "./server-context.js";
|
||||||
import { resolveTargetIdFromTabs } from "./target-id.js";
|
import { resolveTargetIdFromTabs } from "./target-id.js";
|
||||||
|
import { getFreePort } from "./test-port.js";
|
||||||
|
|
||||||
describe("toBoolean", () => {
|
describe("toBoolean", () => {
|
||||||
it("parses yes/no and 1/0", () => {
|
it("parses yes/no and 1/0", () => {
|
||||||
@@ -161,6 +166,31 @@ describe("cdp.helpers", () => {
|
|||||||
});
|
});
|
||||||
expect(headers.Authorization).toBe("Bearer token");
|
expect(headers.Authorization).toBe("Bearer token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not add relay header for unknown loopback ports", () => {
|
||||||
|
const headers = getHeadersWithAuth("http://127.0.0.1:19444/json/version");
|
||||||
|
expect(headers["x-openclaw-relay-token"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds relay header for known relay ports", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const prev = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
|
process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token";
|
||||||
|
try {
|
||||||
|
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||||
|
const headers = getHeadersWithAuth(`${cdpUrl}/json/version`);
|
||||||
|
expect(headers["x-openclaw-relay-token"]).toBeTruthy();
|
||||||
|
expect(headers["x-openclaw-relay-token"]).not.toBe("test-gateway-token");
|
||||||
|
} finally {
|
||||||
|
await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
|
||||||
|
if (prev === undefined) {
|
||||||
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_GATEWAY_TOKEN = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
|
describe("fetchBrowserJson loopback auth (bridge auth registry)", () => {
|
||||||
|
|||||||
65
src/browser/extension-relay-auth.ts
Normal file
65
src/browser/extension-relay-auth.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1";
|
||||||
|
const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500;
|
||||||
|
const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay";
|
||||||
|
|
||||||
|
function resolveGatewayAuthToken(): string | null {
|
||||||
|
const envToken =
|
||||||
|
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
|
||||||
|
if (envToken) {
|
||||||
|
return envToken;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const configToken = cfg.gateway?.auth?.token?.trim();
|
||||||
|
if (configToken) {
|
||||||
|
return configToken;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore config read failures; caller can fallback to per-process random token
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveRelayAuthToken(gatewayToken: string, port: number): string {
|
||||||
|
return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRelayAuthTokenForPort(port: number): string {
|
||||||
|
const gatewayToken = resolveGatewayAuthToken();
|
||||||
|
if (gatewayToken) {
|
||||||
|
return deriveRelayAuthToken(gatewayToken, port);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeAuthenticatedOpenClawRelay(params: {
|
||||||
|
baseUrl: string;
|
||||||
|
relayAuthHeader: string;
|
||||||
|
relayAuthToken: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), params.timeoutMs ?? DEFAULT_RELAY_PROBE_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const versionUrl = new URL("/json/version", `${params.baseUrl}/`).toString();
|
||||||
|
const res = await fetch(versionUrl, {
|
||||||
|
signal: ctrl.signal,
|
||||||
|
headers: { [params.relayAuthHeader]: params.relayAuthToken },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { Browser?: unknown };
|
||||||
|
const browserName = typeof body?.Browser === "string" ? body.Browser.trim() : "";
|
||||||
|
return browserName === OPENCLAW_RELAY_BROWSER;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,11 +170,17 @@ describe("chrome extension relay server", () => {
|
|||||||
ext.close();
|
ext.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses gateway token for relay auth headers on loopback URLs", async () => {
|
it("uses relay-scoped token only for known relay ports", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
|
const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
|
||||||
|
expect(unknown).toEqual({});
|
||||||
|
|
||||||
|
cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
|
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||||
|
|
||||||
|
const headers = getChromeExtensionRelayAuthHeaders(cdpUrl);
|
||||||
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
|
expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
|
||||||
expect(headers["x-openclaw-relay-token"]).toBe(TEST_GATEWAY_TOKEN);
|
expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects CDP access without relay auth token", async () => {
|
it("rejects CDP access without relay auth token", async () => {
|
||||||
@@ -200,13 +206,15 @@ describe("chrome extension relay server", () => {
|
|||||||
expect(err.message).toContain("401");
|
expect(err.message).toContain("401");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts extension websocket access with gateway token query param", async () => {
|
it("accepts extension websocket access with relay token query param", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
cdpUrl = `http://127.0.0.1:${port}`;
|
cdpUrl = `http://127.0.0.1:${port}`;
|
||||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||||
|
|
||||||
|
const token = relayAuthHeaders(`ws://127.0.0.1:${port}/extension`)["x-openclaw-relay-token"];
|
||||||
|
expect(token).toBeTruthy();
|
||||||
const ext = new WebSocket(
|
const ext = new WebSocket(
|
||||||
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
|
`ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(String(token))}`,
|
||||||
);
|
);
|
||||||
await waitForOpen(ext);
|
await waitForOpen(ext);
|
||||||
ext.close();
|
ext.close();
|
||||||
@@ -403,7 +411,20 @@ describe("chrome extension relay server", () => {
|
|||||||
|
|
||||||
it("reuses an already-bound relay port when another process owns it", async () => {
|
it("reuses an already-bound relay port when another process owns it", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
let probeToken: string | undefined;
|
||||||
const fakeRelay = createServer((req, res) => {
|
const fakeRelay = createServer((req, res) => {
|
||||||
|
if (req.url?.startsWith("/json/version")) {
|
||||||
|
const header = req.headers["x-openclaw-relay-token"];
|
||||||
|
probeToken = Array.isArray(header) ? header[0] : header;
|
||||||
|
if (!probeToken) {
|
||||||
|
res.writeHead(401);
|
||||||
|
res.end("Unauthorized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.url?.startsWith("/extension/status")) {
|
if (req.url?.startsWith("/extension/status")) {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ connected: false }));
|
res.end(JSON.stringify({ connected: false }));
|
||||||
@@ -427,6 +448,8 @@ describe("chrome extension relay server", () => {
|
|||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
};
|
};
|
||||||
expect(status.connected).toBe(false);
|
expect(status.connected).toBe(false);
|
||||||
|
expect(probeToken).toBeTruthy();
|
||||||
|
expect(probeToken).not.toBe("test-gateway-token");
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) {
|
if (prev === undefined) {
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { createServer } from "node:http";
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import WebSocket, { WebSocketServer } from "ws";
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
import { loadConfig } from "../config/config.js";
|
|
||||||
import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
|
import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
|
import {
|
||||||
|
probeAuthenticatedOpenClawRelay,
|
||||||
|
resolveRelayAuthTokenForPort,
|
||||||
|
} from "./extension-relay-auth.js";
|
||||||
|
|
||||||
type CdpCommand = {
|
type CdpCommand = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -155,33 +158,15 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
|
const serversByPort = new Map<number, ChromeExtensionRelayServer>();
|
||||||
|
const relayAuthTokensByPort = new Map<number, string>();
|
||||||
|
|
||||||
function resolveGatewayAuthToken(): string | null {
|
function resolveUrlPort(parsed: URL): number | null {
|
||||||
const envToken =
|
const port =
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
|
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
|
||||||
if (envToken) {
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||||
return envToken;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const configToken = cfg.gateway?.auth?.token?.trim();
|
|
||||||
if (configToken) {
|
|
||||||
return configToken;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore config read failures; caller can fallback to per-process random token
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return port;
|
||||||
function resolveRelayAuthToken(): string {
|
|
||||||
const gatewayToken = resolveGatewayAuthToken();
|
|
||||||
if (gatewayToken) {
|
|
||||||
return gatewayToken;
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAddrInUseError(err: unknown): boolean {
|
function isAddrInUseError(err: unknown): boolean {
|
||||||
@@ -193,31 +178,17 @@ function isAddrInUseError(err: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function looksLikeOpenClawRelay(baseUrl: string): Promise<boolean> {
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const timer = setTimeout(() => ctrl.abort(), 500);
|
|
||||||
try {
|
|
||||||
const statusUrl = new URL("/extension/status", `${baseUrl}/`).toString();
|
|
||||||
const res = await fetch(statusUrl, { signal: ctrl.signal });
|
|
||||||
if (!res.ok) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { connected?: unknown };
|
|
||||||
return typeof body.connected === "boolean";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function relayAuthTokenForUrl(url: string): string | null {
|
function relayAuthTokenForUrl(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (!isLoopbackHost(parsed.hostname)) {
|
if (!isLoopbackHost(parsed.hostname)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return resolveGatewayAuthToken();
|
const port = resolveUrlPort(parsed);
|
||||||
|
if (!port || !serversByPort.has(port)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return relayAuthTokensByPort.get(port) ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -244,7 +215,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayAuthToken = resolveRelayAuthToken();
|
const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
|
||||||
|
|
||||||
let extensionWs: WebSocket | null = null;
|
let extensionWs: WebSocket | null = null;
|
||||||
const cdpClients = new Set<WebSocket>();
|
const cdpClients = new Set<WebSocket>();
|
||||||
@@ -771,7 +742,14 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
|||||||
server.once("error", reject);
|
server.once("error", reject);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAddrInUseError(err) && (await looksLikeOpenClawRelay(info.baseUrl))) {
|
if (
|
||||||
|
isAddrInUseError(err) &&
|
||||||
|
(await probeAuthenticatedOpenClawRelay({
|
||||||
|
baseUrl: info.baseUrl,
|
||||||
|
relayAuthHeader: RELAY_AUTH_HEADER,
|
||||||
|
relayAuthToken,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
const existingRelay: ChromeExtensionRelayServer = {
|
const existingRelay: ChromeExtensionRelayServer = {
|
||||||
host: info.host,
|
host: info.host,
|
||||||
port: info.port,
|
port: info.port,
|
||||||
@@ -780,9 +758,11 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
|||||||
extensionConnected: () => false,
|
extensionConnected: () => false,
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
serversByPort.delete(info.port);
|
serversByPort.delete(info.port);
|
||||||
|
relayAuthTokensByPort.delete(info.port);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
serversByPort.set(info.port, existingRelay);
|
serversByPort.set(info.port, existingRelay);
|
||||||
|
relayAuthTokensByPort.set(info.port, relayAuthToken);
|
||||||
return existingRelay;
|
return existingRelay;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -801,6 +781,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
|||||||
extensionConnected: () => Boolean(extensionWs),
|
extensionConnected: () => Boolean(extensionWs),
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
serversByPort.delete(port);
|
serversByPort.delete(port);
|
||||||
|
relayAuthTokensByPort.delete(port);
|
||||||
try {
|
try {
|
||||||
extensionWs?.close(1001, "server stopping");
|
extensionWs?.close(1001, "server stopping");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -822,6 +803,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
serversByPort.set(port, relay);
|
serversByPort.set(port, relay);
|
||||||
|
relayAuthTokensByPort.set(port, relayAuthToken);
|
||||||
return relay;
|
return relay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user