From 7e54b6c96feb1a5c30884f2b32037b8dadd0e532 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:39:34 +0100 Subject: [PATCH] fix(browser): unify extension relay auth on gateway token --- CHANGELOG.md | 1 + assets/chrome-extension/README.md | 1 + assets/chrome-extension/background.js | 17 ++++++- assets/chrome-extension/options.html | 10 ++-- assets/chrome-extension/options.js | 50 ++++++++++++++------ docs/tools/chrome-extension.md | 13 ++++-- src/browser/extension-relay.test.ts | 66 +++++++++++++++++++-------- src/browser/extension-relay.ts | 58 +++++++++++------------ 8 files changed, 146 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de6eac53fa..b2e641c9fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. +- Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting. - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md index 2a2a11a3be5..4ee072c1f2b 100644 --- a/assets/chrome-extension/README.md +++ b/assets/chrome-extension/README.md @@ -20,3 +20,4 @@ Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate i ## Options - `Relay port`: defaults to `18792`. +- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 31ba401bddc..7a1754e06c9 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -42,6 +42,12 @@ async function getRelayPort() { return n } +async function getGatewayToken() { + const stored = await chrome.storage.local.get(['gatewayToken']) + const token = String(stored.gatewayToken || '').trim() + return token || '' +} + function setBadge(tabId, kind) { const cfg = BADGE[kind] void chrome.action.setBadgeText({ tabId, text: cfg.text }) @@ -55,8 +61,11 @@ async function ensureRelayConnection() { relayConnectPromise = (async () => { const port = await getRelayPort() + const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = `ws://127.0.0.1:${port}/extension` + const wsUrl = gatewayToken + ? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}` + : `ws://127.0.0.1:${port}/extension` // Fast preflight: is the relay server up? try { @@ -65,6 +74,12 @@ async function ensureRelayConnection() { throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) } + if (!gatewayToken) { + throw new Error( + 'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)', + ) + } + const ws = new WebSocket(wsUrl) relayWs = ws diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html index 14704d65cf0..17fc6a79eed 100644 --- a/assets/chrome-extension/options.html +++ b/assets/chrome-extension/options.html @@ -176,15 +176,19 @@
-

Relay port

+

Relay connection

+
+ +
+
- Default: 18792. Extension connects to: http://127.0.0.1:<port>/. - Only change this if your OpenClaw profile uses a different cdpUrl port. + Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. + Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN).
diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 5b558ddccf2..e4252ccae4c 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -13,6 +13,12 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } +function relayHeaders(token) { + const t = String(token || '').trim() + if (!t) return {} + return { 'x-openclaw-relay-token': t } +} + function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -20,18 +26,31 @@ function setStatus(kind, message) { status.textContent = message || '' } -async function checkRelayReachable(port) { - const url = `http://127.0.0.1:${port}/` +async function checkRelayReachable(port, token) { + const url = `http://127.0.0.1:${port}/json/version` + const trimmedToken = String(token || '').trim() + if (!trimmedToken) { + setStatus('error', 'Gateway token required. Save your gateway token to connect.') + return + } const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 900) + const t = setTimeout(() => ctrl.abort(), 1200) try { - const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal }) + const res = await fetch(url, { + method: 'GET', + headers: relayHeaders(trimmedToken), + signal: ctrl.signal, + }) + if (res.status === 401) { + setStatus('error', 'Gateway token rejected. Check token and save again.') + return + } if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable at ${url}`) + setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) } catch { setStatus( 'error', - `Relay not reachable at ${url}. Start OpenClaw’s browser relay on this machine, then click the toolbar button again.`, + `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) } finally { clearTimeout(t) @@ -39,20 +58,25 @@ async function checkRelayReachable(port) { } async function load() { - const stored = await chrome.storage.local.get(['relayPort']) + const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) const port = clampPort(stored.relayPort) + const token = String(stored.gatewayToken || '').trim() document.getElementById('port').value = String(port) + document.getElementById('token').value = token updateRelayUrl(port) - await checkRelayReachable(port) + await checkRelayReachable(port, token) } async function save() { - const input = document.getElementById('port') - const port = clampPort(input.value) - await chrome.storage.local.set({ relayPort: port }) - input.value = String(port) + const portInput = document.getElementById('port') + const tokenInput = document.getElementById('token') + const port = clampPort(portInput.value) + const token = String(tokenInput.value || '').trim() + await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) + portInput.value = String(port) + tokenInput.value = token updateRelayUrl(port) - await checkRelayReachable(port) + await checkRelayReachable(port, token) } document.getElementById('save').addEventListener('click', () => void save()) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 4d49c835ed7..6049dfb36a7 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -53,10 +53,15 @@ After upgrading OpenClaw: - Re-run `openclaw browser extension install` to refresh the installed files under your OpenClaw state directory. - Chrome → `chrome://extensions` → click “Reload” on the extension. -## Use it (no extra config) +## Use it (set gateway token once) OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port. +Before first attach, open extension Options and set: + +- `Port` (default `18792`) +- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`) + Use it: - CLI: `openclaw browser --browser-profile chrome tabs` @@ -89,12 +94,12 @@ openclaw browser create-profile \ - `ON`: attached; OpenClaw can drive that tab. - `…`: connecting to the local relay. -- `!`: relay not reachable (most common: browser relay server isn’t running on this machine). +- `!`: relay not reachable/authenticated (most common: relay server not running, or gateway token missing/wrong). If you see `!`: - Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere. -- Open the extension Options page; it shows whether the relay is reachable. +- Open the extension Options page; it validates relay reachability + gateway-token auth. ## Remote Gateway (use a node host) @@ -169,7 +174,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. +- The relay blocks non-extension origins and requires gateway-token auth for both `/cdp` and `/extension`. Related: diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 021778393e6..54e8fb428e6 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; import { ensureChromeExtensionRelayServer, @@ -122,13 +122,25 @@ async function waitForListMatch( } describe("chrome extension relay server", () => { + const TEST_GATEWAY_TOKEN = "test-gateway-token"; let cdpUrl = ""; + let previousGatewayToken: string | undefined; + + beforeEach(() => { + previousGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN; + }); afterEach(async () => { if (cdpUrl) { await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); cdpUrl = ""; } + if (previousGatewayToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayToken; + } }); it("advertises CDP WS only when extension is connected", async () => { @@ -143,7 +155,9 @@ describe("chrome extension relay server", () => { }; expect(v1.webSocketDebuggerUrl).toBeUndefined(); - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); await waitForOpen(ext); const v2 = (await fetch(`${cdpUrl}/json/version`, { @@ -156,21 +170,11 @@ describe("chrome extension relay server", () => { ext.close(); }); - it("derives relay auth headers from gateway token for loopback URLs", async () => { + it("uses gateway token for relay auth headers on loopback URLs", async () => { const port = await getFreePort(); - const prev = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; - try { - const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`); - expect(Object.keys(headers)).toContain("x-openclaw-relay-token"); - expect((headers["x-openclaw-relay-token"] ?? "").length).toBeGreaterThan(20); - } finally { - if (prev === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prev; - } - } + const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`); + expect(Object.keys(headers)).toContain("x-openclaw-relay-token"); + expect(headers["x-openclaw-relay-token"]).toBe(TEST_GATEWAY_TOKEN); }); it("rejects CDP access without relay auth token", async () => { @@ -186,12 +190,36 @@ describe("chrome extension relay server", () => { expect(err.message).toContain("401"); }); - it("tracks attached page targets and exposes them via CDP + /json/list", async () => { + it("rejects extension websocket access without relay auth token", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; await ensureChromeExtensionRelayServer({ cdpUrl }); const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const err = await waitForError(ext); + expect(err.message).toContain("401"); + }); + + it("accepts extension websocket access with gateway token query param", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const ext = new WebSocket( + `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`, + ); + await waitForOpen(ext); + ext.close(); + }); + + it("tracks attached page targets and exposes them via CDP + /json/list", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); await waitForOpen(ext); // Simulate a tab attach coming from the extension. @@ -307,7 +335,9 @@ describe("chrome extension relay server", () => { cdpUrl = `http://127.0.0.1:${port}`; await ensureChromeExtensionRelayServer({ cdpUrl }); - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); await waitForOpen(ext); const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 53a38e3ac73..e65500a5d44 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -1,8 +1,7 @@ -import { createHash, randomBytes } from "node:crypto"; import type { IncomingMessage } from "node:http"; -import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; +import { createServer } from "node:http"; import WebSocket, { WebSocketServer } from "ws"; import { loadConfig } from "../config/config.js"; import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; @@ -94,6 +93,18 @@ function getHeader(req: IncomingMessage, name: string): string | undefined { return headerValue(req.headers[name.toLowerCase()]); } +function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined { + const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim(); + if (headerToken) { + return headerToken; + } + const queryToken = url?.searchParams.get("token")?.trim(); + if (queryToken) { + return queryToken; + } + return undefined; +} + export type ChromeExtensionRelayServer = { host: string; port: number; @@ -144,7 +155,6 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { } const serversByPort = new Map(); -const relayAuthByPort = new Map(); function resolveGatewayAuthToken(): string | null { const envToken = @@ -164,19 +174,14 @@ function resolveGatewayAuthToken(): string | null { return null; } -function deriveDeterministicRelayAuthToken(port: number): string | null { +function resolveRelayAuthToken(): string { const gatewayToken = resolveGatewayAuthToken(); - if (!gatewayToken) { - return null; + if (gatewayToken) { + return gatewayToken; } - return createHash("sha256") - .update(`openclaw-relay:${port}:`) - .update(gatewayToken) - .digest("base64url"); -} - -function resolveRelayAuthToken(port: number): string { - return deriveDeterministicRelayAuthToken(port) ?? randomBytes(32).toString("base64url"); + throw new Error( + "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)", + ); } function isAddrInUseError(err: unknown): boolean { @@ -212,16 +217,7 @@ function relayAuthTokenForUrl(url: string): string | null { 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) ?? deriveDeterministicRelayAuthToken(port); + return resolveGatewayAuthToken(); } catch { return null; } @@ -248,7 +244,7 @@ export async function ensureChromeExtensionRelayServer(opts: { return existing; } - const relayAuthToken = resolveRelayAuthToken(info.port); + const relayAuthToken = resolveRelayAuthToken(); let extensionWs: WebSocket | null = null; const cdpClients = new Set(); @@ -529,6 +525,11 @@ export async function ensureChromeExtensionRelayServer(opts: { } if (pathname === "/extension") { + const token = getRelayAuthTokenFromRequest(req, url); + if (!token || token !== relayAuthToken) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } if (extensionWs) { rejectUpgrade(socket, 409, "Extension already connected"); return; @@ -540,7 +541,7 @@ export async function ensureChromeExtensionRelayServer(opts: { } if (pathname === "/cdp") { - const token = getHeader(req, RELAY_AUTH_HEADER); + const token = getRelayAuthTokenFromRequest(req, url); if (!token || token !== relayAuthToken) { rejectUpgrade(socket, 401, "Unauthorized"); return; @@ -779,10 +780,8 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => false, stop: async () => { serversByPort.delete(info.port); - relayAuthByPort.delete(info.port); }, }; - relayAuthByPort.set(info.port, relayAuthToken); serversByPort.set(info.port, existingRelay); return existingRelay; } @@ -802,7 +801,6 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => Boolean(extensionWs), stop: async () => { serversByPort.delete(port); - relayAuthByPort.delete(port); try { extensionWs?.close(1001, "server stopping"); } catch { @@ -823,7 +821,6 @@ export async function ensureChromeExtensionRelayServer(opts: { }, }; - relayAuthByPort.set(port, relayAuthToken); serversByPort.set(port, relay); return relay; } @@ -835,6 +832,5 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): return false; } await existing.stop(); - relayAuthByPort.delete(info.port); return true; }