diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index dcd616913d2..6194507529a 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -610,6 +610,7 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Disable browser sync/password managers in the agent profile if possible (reduces blast radius). - For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach. - Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet. +- The Chrome extension relay’s CDP endpoint is auth-gated; only OpenClaw clients can connect. - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). - Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 8d042ee9f6f..4d49c835ed7 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -169,6 +169,7 @@ Recommendations: - Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage. - Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing. - Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public). +- The relay blocks non-extension origins and requires an internal auth token for CDP clients. Related: diff --git a/src/browser/cdp.helpers.ts b/src/browser/cdp.helpers.ts index f34e16edda1..05458c9a3ec 100644 --- a/src/browser/cdp.helpers.ts +++ b/src/browser/cdp.helpers.ts @@ -1,5 +1,6 @@ import WebSocket from "ws"; import { rawDataToString } from "../infra/ws.js"; +import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js"; type CdpResponse = { id: number; @@ -28,20 +29,24 @@ export function isLoopbackHost(host: string) { } export function getHeadersWithAuth(url: string, headers: Record = {}) { + const relayHeaders = getChromeExtensionRelayAuthHeaders(url); + const mergedHeaders = { ...relayHeaders, ...headers }; try { const parsed = new URL(url); - const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization"); + const hasAuthHeader = Object.keys(mergedHeaders).some( + (key) => key.toLowerCase() === "authorization", + ); if (hasAuthHeader) { - return headers; + return mergedHeaders; } if (parsed.username || parsed.password) { const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); - return { ...headers, Authorization: `Basic ${auth}` }; + return { ...mergedHeaders, Authorization: `Basic ${auth}` }; } } catch { // ignore } - return headers; + return mergedHeaders; } export function appendCdpPath(cdpUrl: string, path: string): string { diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 87f1fe449d1..a6484755810 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; import { ensureChromeExtensionRelayServer, + getChromeExtensionRelayAuthHeaders, stopChromeExtensionRelayServer, } from "./extension-relay.js"; @@ -30,6 +31,17 @@ function waitForOpen(ws: WebSocket) { }); } +function waitForError(ws: WebSocket) { + return new Promise((resolve, reject) => { + ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err)))); + ws.once("open", () => reject(new Error("expected websocket error"))); + }); +} + +function relayAuthHeaders(url: string) { + return getChromeExtensionRelayAuthHeaders(url); +} + function createMessageQueue(ws: WebSocket) { const queue: string[] = []; let waiter: ((value: string) => void) | null = null; @@ -137,7 +149,9 @@ describe("chrome extension relay server", () => { cdpUrl = `http://127.0.0.1:${port}`; await ensureChromeExtensionRelayServer({ cdpUrl }); - const v1 = (await fetch(`${cdpUrl}/json/version`).then((r) => r.json())) as { + const v1 = (await fetch(`${cdpUrl}/json/version`, { + headers: relayAuthHeaders(cdpUrl), + }).then((r) => r.json())) as { webSocketDebuggerUrl?: string; }; expect(v1.webSocketDebuggerUrl).toBeUndefined(); @@ -145,7 +159,9 @@ describe("chrome extension relay server", () => { const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); await waitForOpen(ext); - const v2 = (await fetch(`${cdpUrl}/json/version`).then((r) => r.json())) as { + const v2 = (await fetch(`${cdpUrl}/json/version`, { + headers: relayAuthHeaders(cdpUrl), + }).then((r) => r.json())) as { webSocketDebuggerUrl?: string; }; expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`); @@ -153,6 +169,19 @@ describe("chrome extension relay server", () => { ext.close(); }); + it("rejects CDP access without relay auth token", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const res = await fetch(`${cdpUrl}/json/version`); + expect(res.status).toBe(401); + + const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`); + const err = await waitForError(cdp); + expect(err.message).toContain("401"); + }); + it("tracks attached page targets and exposes them via CDP + /json/list", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; @@ -181,7 +210,9 @@ describe("chrome extension relay server", () => { }), ); - const list = (await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{ + const list = (await fetch(`${cdpUrl}/json/list`, { + headers: relayAuthHeaders(cdpUrl), + }).then((r) => r.json())) as Array<{ id?: string; url?: string; title?: string; @@ -208,7 +239,9 @@ describe("chrome extension relay server", () => { const list2 = await waitForListMatch( async () => - (await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{ + (await fetch(`${cdpUrl}/json/list`, { + headers: relayAuthHeaders(cdpUrl), + }).then((r) => r.json())) as Array<{ id?: string; url?: string; title?: string; @@ -226,7 +259,9 @@ describe("chrome extension relay server", () => { ), ).toBe(true); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`); + const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), + }); await waitForOpen(cdp); const q = createMessageQueue(cdp); @@ -271,7 +306,9 @@ describe("chrome extension relay server", () => { const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); await waitForOpen(ext); - const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`); + const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`), + }); await waitForOpen(cdp); const q = createMessageQueue(cdp); diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 6c9164f0f0d..9919b7f103c 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -1,5 +1,7 @@ +import type { IncomingMessage } from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; +import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import WebSocket, { WebSocketServer } from "ws"; import { rawDataToString } from "../infra/ws.js"; @@ -74,6 +76,22 @@ type ConnectedTarget = { targetInfo: TargetInfo; }; +const RELAY_AUTH_HEADER = "x-openclaw-relay-token"; + +function headerValue(value: string | string[] | undefined): string | undefined { + if (!value) { + return undefined; + } + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +function getHeader(req: IncomingMessage, name: string): string | undefined { + return headerValue(req.headers[name.toLowerCase()]); +} + export type ChromeExtensionRelayServer = { host: string; port: number; @@ -156,6 +174,36 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { } const serversByPort = new Map(); +const relayAuthByPort = new Map(); + +function relayAuthTokenForUrl(url: string): string | null { + try { + const parsed = new URL(url); + if (!isLoopbackHost(parsed.hostname)) { + return null; + } + const port = + parsed.port?.trim() !== "" + ? Number(parsed.port) + : parsed.protocol === "https:" || parsed.protocol === "wss:" + ? 443 + : 80; + if (!Number.isFinite(port)) { + return null; + } + return relayAuthByPort.get(port) ?? null; + } catch { + return null; + } +} + +export function getChromeExtensionRelayAuthHeaders(url: string): Record { + const token = relayAuthTokenForUrl(url); + if (!token) { + return {}; + } + return { [RELAY_AUTH_HEADER]: token }; +} export async function ensureChromeExtensionRelayServer(opts: { cdpUrl: string; @@ -309,10 +357,21 @@ export async function ensureChromeExtensionRelayServer(opts: { } }; + const relayAuthToken = randomBytes(32).toString("base64url"); + const server = createServer((req, res) => { const url = new URL(req.url ?? "/", info.baseUrl); const path = url.pathname; + if (path.startsWith("/json")) { + const token = getHeader(req, RELAY_AUTH_HEADER); + if (!token || token !== relayAuthToken) { + res.writeHead(401); + res.end("Unauthorized"); + return; + } + } + if (req.method === "HEAD" && path === "/") { res.writeHead(200); res.end(); @@ -433,6 +492,12 @@ export async function ensureChromeExtensionRelayServer(opts: { return; } + const origin = headerValue(req.headers.origin); + if (origin && !origin.startsWith("chrome-extension://")) { + rejectUpgrade(socket, 403, "Forbidden: invalid origin"); + return; + } + if (pathname === "/extension") { if (extensionWs) { rejectUpgrade(socket, 409, "Extension already connected"); @@ -445,6 +510,11 @@ export async function ensureChromeExtensionRelayServer(opts: { } if (pathname === "/cdp") { + const token = getHeader(req, RELAY_AUTH_HEADER); + if (!token || token !== relayAuthToken) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } if (!extensionWs) { rejectUpgrade(socket, 503, "Extension not connected"); return; @@ -682,6 +752,7 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => Boolean(extensionWs), stop: async () => { serversByPort.delete(port); + relayAuthByPort.delete(port); try { extensionWs?.close(1001, "server stopping"); } catch { @@ -702,6 +773,7 @@ export async function ensureChromeExtensionRelayServer(opts: { }, }; + relayAuthByPort.set(port, relayAuthToken); serversByPort.set(port, relay); return relay; } @@ -713,5 +785,6 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): return false; } await existing.stop(); + relayAuthByPort.delete(info.port); return true; } diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 8b726f1524d..7d72b3b13a4 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -400,7 +400,8 @@ async function findPageByTargetId( .replace(/\/+$/, "") .replace(/^ws:/, "http:") .replace(/\/cdp$/, ""); - const response = await fetch(`${baseUrl}/json/list`); + const listUrl = `${baseUrl}/json/list`; + const response = await fetch(listUrl, { headers: getHeadersWithAuth(listUrl) }); if (response.ok) { const targets = (await response.json()) as Array<{ id: string;