diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acc..64a060443 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1665,8 +1665,41 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // `authToken` (the cfg-derived value) explicitly. const browserManager = cfgBrowserManager; + // CORS wrapper for the Side Panel. The extension's sidepanel.html runs in a + // chrome-extension:// origin and fetches /health, /command, /sse-session, + // /refs, /activity/stream, /pty-session, etc. /health is localhost-only, so + // reflecting Origin back is safe — we only do it for chrome-extension:// + // requests, not arbitrary websites. + const withCors = ( + handler: (req: Request) => Promise, + ) => async (req: Request): Promise => { + const origin = req.headers.get('origin'); + const isExt = origin?.startsWith('chrome-extension://'); + if (isExt && req.method === 'OPTIONS') { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': origin!, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Max-Age': '86400', + }, + }); + } + const resp = await handler(req); + if (!isExt) return resp; + const headers = new Headers(resp.headers); + headers.set('Access-Control-Allow-Origin', origin!); + headers.set('Access-Control-Allow-Credentials', 'true'); + return new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers, + }); + }; - const makeFetchHandler = (surface: Surface) => async (req: Request): Promise => { + const makeFetchHandler = (surface: Surface) => withCors(async (req: Request): Promise => { const url = new URL(req.url); // ─── Tunnel surface filter (runs before any route dispatch) ── @@ -2846,7 +2879,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { } return new Response('Not found', { status: 404 }); - }; + }); return { fetchLocal: makeFetchHandler('local'), diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 2e39d99e4..406233c6e 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -562,9 +562,18 @@ function buildServer() { if (url.pathname === '/claude-available' && req.method === 'GET') { writeClaudeAvailable(); const found = findClaude(); + const origin = req.headers.get('origin'); return new Response(JSON.stringify({ available: !!found, path: found }), { status: 200, - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(origin?.startsWith('chrome-extension://') + ? { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + } + : {}), + }, }); } diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 2469a121b..b80a610c4 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -61,6 +61,28 @@ describe('Server auth security', () => { expect(refsBlock).not.toContain("'*'"); }); + // Test 3a: withCors middleware exists and reflects only chrome-extension origins. + // Background: the Side Panel runs in a chrome-extension:// origin and fetches + // /health, /command, /refs, /activity/stream, etc. with credentials:'include'. + // Without CORS reflection, every fetch fails and the panel never reaches the + // "connected" state. Reflecting * would be unsafe; reflecting only + // chrome-extension:// keeps arbitrary websites blocked. + test('withCors middleware reflects only chrome-extension origins', () => { + expect(SERVER_SRC).toContain('const withCors = '); + expect(SERVER_SRC).toContain("origin?.startsWith('chrome-extension://')"); + // Must include Allow-Credentials because the panel uses credentials:'include'. + expect(SERVER_SRC).toContain("'Access-Control-Allow-Credentials'"); + // Must NOT wildcard. + expect(SERVER_SRC).not.toContain("'Access-Control-Allow-Origin': '*'"); + }); + + // Test 3b: makeFetchHandler is wrapped with withCors so every route gets + // CORS handling for free (otherwise a future endpoint added without + // remembering CORS would silently break the Side Panel again). + test('makeFetchHandler is wrapped with withCors', () => { + expect(SERVER_SRC).toMatch(/makeFetchHandler[^=]*=[^=]*withCors\(/); + }); + // Test 4: /activity/history requires auth via validateAuth test('/activity/history requires authentication', () => { const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Batch endpoint'); diff --git a/browse/test/terminal-agent.test.ts b/browse/test/terminal-agent.test.ts index d908052d2..b0dd382dc 100644 --- a/browse/test/terminal-agent.test.ts +++ b/browse/test/terminal-agent.test.ts @@ -221,3 +221,19 @@ describe('Source-level guard: server.ts /pty-session route', () => { expect(route).toContain('buildPtySetCookie'); }); }); + +describe('Source-level guard: terminal-agent /claude-available CORS', () => { + // Background: sidepanel-terminal.js fetches /claude-available with + // credentials:'include' to decide whether to show the "install Claude + // Code" bootstrap card. Without CORS reflection, the fetch throws and + // the card stays stuck on "not found" even when claude is installed. + test('/claude-available reflects Allow-Origin + Allow-Credentials for chrome-extension origins', () => { + const route = AGENT_SRC.slice(AGENT_SRC.indexOf("url.pathname === '/claude-available'")); + const responseBlock = route.slice(0, route.indexOf("// /ws")); + expect(responseBlock).toContain("origin?.startsWith('chrome-extension://')"); + expect(responseBlock).toContain("'Access-Control-Allow-Origin'"); + expect(responseBlock).toContain("'Access-Control-Allow-Credentials'"); + // Must NOT wildcard. + expect(responseBlock).not.toContain("'Access-Control-Allow-Origin': '*'"); + }); +});