diff --git a/CHANGELOG.md b/CHANGELOG.md index fae0d2dab5e..ed4ec444562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Browser/Extension relay CORS: handle `/json*` `OPTIONS` preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842) - Auth/Auth profiles: normalize `auth-profiles.json` alias fields (`mode -> type`, `apiKey -> key`) before credential validation so entries copied from `openclaw.json` auth examples are no longer silently dropped. (#26950) thanks @byungsker. - Cron/Hooks isolated routing: preserve canonical `agent:*` session keys in isolated runs so already-qualified keys are not double-prefixed (for example `agent:main:main` no longer becomes `agent:main:agent:main:main`). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282) - iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman. diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 84a84af6f75..5405dc93e33 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -208,6 +208,61 @@ describe("chrome extension relay server", () => { expect(err.message).toContain("401"); }); + it("allows CORS preflight from chrome-extension origins", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const origin = "chrome-extension://abcdefghijklmnop"; + const res = await fetch(`${cdpUrl}/json/version`, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "x-openclaw-relay-token", + }, + }); + + expect(res.status).toBe(204); + expect(res.headers.get("access-control-allow-origin")).toBe(origin); + expect(res.headers.get("access-control-allow-headers") ?? "").toContain( + "x-openclaw-relay-token", + ); + }); + + it("rejects CORS preflight from non-extension origins", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const res = await fetch(`${cdpUrl}/json/version`, { + method: "OPTIONS", + headers: { + Origin: "https://example.com", + "Access-Control-Request-Method": "GET", + }, + }); + + expect(res.status).toBe(403); + }); + + it("returns CORS headers on JSON responses for extension origins", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const origin = "chrome-extension://abcdefghijklmnop"; + const res = await fetch(`${cdpUrl}/json/version`, { + headers: { + Origin: origin, + ...relayAuthHeaders(cdpUrl), + }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("access-control-allow-origin")).toBe(origin); + }); + it("rejects extension websocket access without relay auth token", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 0036f47f263..a51b7e7e5e7 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -365,6 +365,38 @@ export async function ensureChromeExtensionRelayServer(opts: { const server = createServer((req, res) => { const url = new URL(req.url ?? "/", info.baseUrl); const path = url.pathname; + const origin = getHeader(req, "origin"); + const isChromeExtensionOrigin = + typeof origin === "string" && origin.startsWith("chrome-extension://"); + + if (isChromeExtensionOrigin && origin) { + // Let extension pages call relay HTTP endpoints cross-origin. + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + } + + // Handle CORS preflight requests from the browser extension. + if (req.method === "OPTIONS") { + if (origin && !isChromeExtensionOrigin) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "") + .split(",") + .map((header) => header.trim().toLowerCase()) + .filter((header) => header.length > 0); + const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]); + res.writeHead(204, { + "Access-Control-Allow-Origin": origin ?? "*", + "Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS", + "Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "), + "Access-Control-Max-Age": "86400", + Vary: "Origin, Access-Control-Request-Headers", + }); + res.end(); + return; + } if (path.startsWith("/json")) { const token = getHeader(req, RELAY_AUTH_HEADER)?.trim();