diff --git a/src/browser/browser-utils.test.ts b/src/browser/browser-utils.test.ts index 61641aa3142..80ad76c655f 100644 --- a/src/browser/browser-utils.test.ts +++ b/src/browser/browser-utils.test.ts @@ -3,10 +3,15 @@ import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js"; import { __test } from "./client-fetch.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { shouldRejectBrowserMutation } from "./csrf.js"; +import { + ensureChromeExtensionRelayServer, + stopChromeExtensionRelayServer, +} from "./extension-relay.js"; import { toBoolean } from "./routes/utils.js"; import type { BrowserServerState } from "./server-context.js"; import { listKnownProfileNames } from "./server-context.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; +import { getFreePort } from "./test-port.js"; describe("toBoolean", () => { it("parses yes/no and 1/0", () => { @@ -161,6 +166,31 @@ describe("cdp.helpers", () => { }); 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)", () => { diff --git a/src/browser/extension-relay-auth.ts b/src/browser/extension-relay-auth.ts new file mode 100644 index 00000000000..40de39ae746 --- /dev/null +++ b/src/browser/extension-relay-auth.ts @@ -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 { + 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); + } +} diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 54e8fb428e6..15ecf0e6adb 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -170,11 +170,17 @@ describe("chrome extension relay server", () => { 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 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(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 () => { @@ -200,13 +206,15 @@ describe("chrome extension relay server", () => { 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(); cdpUrl = `http://127.0.0.1:${port}`; await ensureChromeExtensionRelayServer({ cdpUrl }); + const token = relayAuthHeaders(`ws://127.0.0.1:${port}/extension`)["x-openclaw-relay-token"]; + expect(token).toBeTruthy(); 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); ext.close(); @@ -403,7 +411,20 @@ describe("chrome extension relay server", () => { it("reuses an already-bound relay port when another process owns it", async () => { const port = await getFreePort(); + let probeToken: string | undefined; 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")) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ connected: false })); @@ -427,6 +448,8 @@ describe("chrome extension relay server", () => { connected?: boolean; }; expect(status.connected).toBe(false); + expect(probeToken).toBeTruthy(); + expect(probeToken).not.toBe("test-gateway-token"); } finally { if (prev === undefined) { delete process.env.OPENCLAW_GATEWAY_TOKEN; diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 6b799cc0fa8..5f26ae4ed11 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -3,9 +3,12 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; import WebSocket, { WebSocketServer } from "ws"; -import { loadConfig } from "../config/config.js"; import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; import { rawDataToString } from "../infra/ws.js"; +import { + probeAuthenticatedOpenClawRelay, + resolveRelayAuthTokenForPort, +} from "./extension-relay-auth.js"; type CdpCommand = { id: number; @@ -155,33 +158,15 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { } const serversByPort = new Map(); +const relayAuthTokensByPort = new Map(); -function resolveGatewayAuthToken(): string | null { - const envToken = - process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); - if (envToken) { - return envToken; +function resolveUrlPort(parsed: URL): number | null { + const port = + parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80; + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return null; } - 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 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)", - ); + return port; } function isAddrInUseError(err: unknown): boolean { @@ -193,31 +178,17 @@ function isAddrInUseError(err: unknown): boolean { ); } -async function looksLikeOpenClawRelay(baseUrl: string): Promise { - 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 { try { const parsed = new URL(url); if (!isLoopbackHost(parsed.hostname)) { return null; } - return resolveGatewayAuthToken(); + const port = resolveUrlPort(parsed); + if (!port || !serversByPort.has(port)) { + return null; + } + return relayAuthTokensByPort.get(port) ?? null; } catch { return null; } @@ -244,7 +215,7 @@ export async function ensureChromeExtensionRelayServer(opts: { return existing; } - const relayAuthToken = resolveRelayAuthToken(); + const relayAuthToken = resolveRelayAuthTokenForPort(info.port); let extensionWs: WebSocket | null = null; const cdpClients = new Set(); @@ -771,7 +742,14 @@ export async function ensureChromeExtensionRelayServer(opts: { server.once("error", reject); }); } 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 = { host: info.host, port: info.port, @@ -780,9 +758,11 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => false, stop: async () => { serversByPort.delete(info.port); + relayAuthTokensByPort.delete(info.port); }, }; serversByPort.set(info.port, existingRelay); + relayAuthTokensByPort.set(info.port, relayAuthToken); return existingRelay; } throw err; @@ -801,6 +781,7 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => Boolean(extensionWs), stop: async () => { serversByPort.delete(port); + relayAuthTokensByPort.delete(port); try { extensionWs?.close(1001, "server stopping"); } catch { @@ -822,6 +803,7 @@ export async function ensureChromeExtensionRelayServer(opts: { }; serversByPort.set(port, relay); + relayAuthTokensByPort.set(port, relayAuthToken); return relay; }