fix(browser): derive relay auth token from gateway token in Chrome extension

The extension relay server authenticates using an HMAC-SHA256 derived
token (`openclaw-extension-relay-v1:<port>`), but the Chrome extension
was sending the raw gateway token. This caused both the WebSocket
connection and the options page validation to fail with 401 Unauthorized.

Additionally, the options page validation request triggered a CORS
preflight (due to the custom `x-openclaw-relay-token` header) which the
relay rejects because OPTIONS requests lack auth headers. The options
page now delegates the check to the background service worker which has
host_permissions and bypasses CORS preflight.

Fixes #23842

Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit bbc654b9f0)
This commit is contained in:
oneaix
2026-02-23 19:30:36 +08:00
committed by Peter Steinberger
parent bb8f538cd4
commit 216d99e585
4 changed files with 71 additions and 24 deletions

View File

@@ -11,14 +11,32 @@ export function reconnectDelayMs(
return backoff + Math.max(0, jitterMs) * random();
}
export function buildRelayWsUrl(port, gatewayToken) {
export async function deriveRelayToken(gatewayToken, port) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(gatewayToken),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`openclaw-extension-relay-v1:${port}`),
);
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function buildRelayWsUrl(port, gatewayToken) {
const token = String(gatewayToken || "").trim();
if (!token) {
throw new Error(
"Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)",
);
}
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`;
const relayToken = await deriveRelayToken(token, port);
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`;
}
export function isRetryableReconnectError(err) {

View File

@@ -1,4 +1,4 @@
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
import { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
const DEFAULT_PORT = 18792
@@ -128,7 +128,7 @@ async function ensureRelayConnection() {
const port = await getRelayPort()
const gatewayToken = await getGatewayToken()
const httpBase = `http://127.0.0.1:${port}`
const wsUrl = buildRelayWsUrl(port, gatewayToken)
const wsUrl = await buildRelayWsUrl(port, gatewayToken)
// Fast preflight: is the relay server up?
try {
@@ -798,3 +798,16 @@ async function whenReady(fn) {
await initPromise
return fn()
}
// Relay check handler for the options page. The service worker has
// host_permissions and bypasses CORS preflight, so the options page
// delegates token-validation requests here.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'relayCheck') return false
const { url, token } = msg
const headers = token ? { 'x-openclaw-relay-token': token } : {}
fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) })
.then((res) => sendResponse({ status: res.status, ok: res.ok }))
.catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
return true
})

View File

@@ -13,10 +13,15 @@ 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 }
async function deriveRelayToken(gatewayToken, port) {
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw', enc.encode(gatewayToken), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
)
const sig = await crypto.subtle.sign(
'HMAC', key, enc.encode(`openclaw-extension-relay-v1:${port}`),
)
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
function setStatus(kind, message) {
@@ -33,18 +38,21 @@ async function checkRelayReachable(port, token) {
setStatus('error', 'Gateway token required. Save your gateway token to connect.')
return
}
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 1200)
try {
const res = await fetch(url, {
method: 'GET',
headers: relayHeaders(trimmedToken),
signal: ctrl.signal,
const relayToken = await deriveRelayToken(trimmedToken, port)
// Delegate the fetch to the background service worker to bypass
// CORS preflight on the custom x-openclaw-relay-token header.
const res = await chrome.runtime.sendMessage({
type: 'relayCheck',
url,
token: relayToken,
})
if (!res) throw new Error('No response from service worker')
if (res.status === 401) {
setStatus('error', 'Gateway token rejected. Check token and save again.')
return
}
if (res.error) throw new Error(res.error)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`)
} catch {
@@ -52,8 +60,6 @@ async function checkRelayReachable(port, token) {
'error',
`Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`,
)
} finally {
clearTimeout(t)
}
}

View File

@@ -2,7 +2,8 @@ import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
type BackgroundUtilsModule = {
buildRelayWsUrl: (port: number, gatewayToken: string) => string;
buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>;
deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>;
isRetryableReconnectError: (err: unknown) => boolean;
reconnectDelayMs: (
attempt: number,
@@ -25,18 +26,27 @@ async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
}
}
const { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } =
const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } =
await loadBackgroundUtils();
describe("chrome extension background utils", () => {
it("builds websocket url with encoded gateway token", () => {
const url = buildRelayWsUrl(18792, "abc/+= token");
expect(url).toBe("ws://127.0.0.1:18792/extension?token=abc%2F%2B%3D%20token");
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
const relayToken = await deriveRelayToken("test-gateway-token", 18792);
expect(relayToken).toMatch(/^[0-9a-f]{64}$/);
const relayToken2 = await deriveRelayToken("test-gateway-token", 18792);
expect(relayToken).toBe(relayToken2);
const differentPort = await deriveRelayToken("test-gateway-token", 9999);
expect(relayToken).not.toBe(differentPort);
});
it("throws when gateway token is missing", () => {
expect(() => buildRelayWsUrl(18792, "")).toThrow(/Missing gatewayToken/);
expect(() => buildRelayWsUrl(18792, " ")).toThrow(/Missing gatewayToken/);
it("builds websocket url with derived relay token", async () => {
const url = await buildRelayWsUrl(18792, "test-token");
expect(url).toMatch(/^ws:\/\/127\.0\.0\.1:18792\/extension\?token=[0-9a-f]{64}$/);
});
it("throws when gateway token is missing", async () => {
await expect(buildRelayWsUrl(18792, "")).rejects.toThrow(/Missing gatewayToken/);
await expect(buildRelayWsUrl(18792, " ")).rejects.toThrow(/Missing gatewayToken/);
});
it("uses exponential backoff from attempt index", () => {