Skip to content

Commit 4c75eca

Browse files
fix(browser): land PR #23962 extension relay CORS fix
Reworks browser relay CORS handling for extension-origin preflight and JSON responses, adds regression tests, and updates changelog. Landed from contributor @miloudbelarebia (PR #23962). Co-authored-by: Miloud Belarebia <[email protected]>
1 parent 081b1aa commit 4c75eca

File tree

3 files changed

+88
-0
lines changed

3 files changed

+88
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- 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)
1617
- 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.
1718
- 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)
1819
- 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.

src/browser/extension-relay.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,61 @@ describe("chrome extension relay server", () => {
208208
expect(err.message).toContain("401");
209209
});
210210

211+
it("allows CORS preflight from chrome-extension origins", async () => {
212+
const port = await getFreePort();
213+
cdpUrl = `http://127.0.0.1:${port}`;
214+
await ensureChromeExtensionRelayServer({ cdpUrl });
215+
216+
const origin = "chrome-extension://abcdefghijklmnop";
217+
const res = await fetch(`${cdpUrl}/json/version`, {
218+
method: "OPTIONS",
219+
headers: {
220+
Origin: origin,
221+
"Access-Control-Request-Method": "GET",
222+
"Access-Control-Request-Headers": "x-openclaw-relay-token",
223+
},
224+
});
225+
226+
expect(res.status).toBe(204);
227+
expect(res.headers.get("access-control-allow-origin")).toBe(origin);
228+
expect(res.headers.get("access-control-allow-headers") ?? "").toContain(
229+
"x-openclaw-relay-token",
230+
);
231+
});
232+
233+
it("rejects CORS preflight from non-extension origins", async () => {
234+
const port = await getFreePort();
235+
cdpUrl = `http://127.0.0.1:${port}`;
236+
await ensureChromeExtensionRelayServer({ cdpUrl });
237+
238+
const res = await fetch(`${cdpUrl}/json/version`, {
239+
method: "OPTIONS",
240+
headers: {
241+
Origin: "https://example.com",
242+
"Access-Control-Request-Method": "GET",
243+
},
244+
});
245+
246+
expect(res.status).toBe(403);
247+
});
248+
249+
it("returns CORS headers on JSON responses for extension origins", async () => {
250+
const port = await getFreePort();
251+
cdpUrl = `http://127.0.0.1:${port}`;
252+
await ensureChromeExtensionRelayServer({ cdpUrl });
253+
254+
const origin = "chrome-extension://abcdefghijklmnop";
255+
const res = await fetch(`${cdpUrl}/json/version`, {
256+
headers: {
257+
Origin: origin,
258+
...relayAuthHeaders(cdpUrl),
259+
},
260+
});
261+
262+
expect(res.status).toBe(200);
263+
expect(res.headers.get("access-control-allow-origin")).toBe(origin);
264+
});
265+
211266
it("rejects extension websocket access without relay auth token", async () => {
212267
const port = await getFreePort();
213268
cdpUrl = `http://127.0.0.1:${port}`;

src/browser/extension-relay.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,38 @@ export async function ensureChromeExtensionRelayServer(opts: {
365365
const server = createServer((req, res) => {
366366
const url = new URL(req.url ?? "/", info.baseUrl);
367367
const path = url.pathname;
368+
const origin = getHeader(req, "origin");
369+
const isChromeExtensionOrigin =
370+
typeof origin === "string" && origin.startsWith("chrome-extension://");
371+
372+
if (isChromeExtensionOrigin && origin) {
373+
// Let extension pages call relay HTTP endpoints cross-origin.
374+
res.setHeader("Access-Control-Allow-Origin", origin);
375+
res.setHeader("Vary", "Origin");
376+
}
377+
378+
// Handle CORS preflight requests from the browser extension.
379+
if (req.method === "OPTIONS") {
380+
if (origin && !isChromeExtensionOrigin) {
381+
res.writeHead(403);
382+
res.end("Forbidden");
383+
return;
384+
}
385+
const requestedHeaders = (getHeader(req, "access-control-request-headers") ?? "")
386+
.split(",")
387+
.map((header) => header.trim().toLowerCase())
388+
.filter((header) => header.length > 0);
389+
const allowedHeaders = new Set(["content-type", RELAY_AUTH_HEADER, ...requestedHeaders]);
390+
res.writeHead(204, {
391+
"Access-Control-Allow-Origin": origin ?? "*",
392+
"Access-Control-Allow-Methods": "GET, PUT, POST, OPTIONS",
393+
"Access-Control-Allow-Headers": Array.from(allowedHeaders).join(", "),
394+
"Access-Control-Max-Age": "86400",
395+
Vary: "Origin, Access-Control-Request-Headers",
396+
});
397+
res.end();
398+
return;
399+
}
368400

369401
if (path.startsWith("/json")) {
370402
const token = getHeader(req, RELAY_AUTH_HEADER)?.trim();

0 commit comments

Comments
 (0)