mirror of https://github.com/garrytan/gstack.git
fix: reflect CORS for chrome-extension origins so Side Panel fetches succeed
The Side Panel runs in a chrome-extension:// origin and fetches /health, /command, /refs, /activity/stream, and the terminal-agent's /claude-available with credentials:'include'. None of those responses set Access-Control-Allow-Origin or Access-Control-Allow-Credentials, so every fetch fails CORS and the panel never reaches the connected state — the bootstrap card stays stuck on "Browse server not ready" and the install-card shows "Claude Code not found" even when claude is on PATH. Repro: launch `browse connect`, open the Side Panel from a fresh extension load, watch DevTools console fire "Access to fetch at 'http://127.0.0.1:34567/health' from origin 'chrome-extension://...' has been blocked by CORS policy". Fix: * server.ts — add a `withCors` middleware (preflight OPTIONS 204 + response-header reflection) and wrap both makeFetchHandler call sites. Reflects the Origin header back ONLY when it starts with chrome-extension://, so arbitrary websites still get no CORS headers. Sets Access-Control-Allow-Credentials: true to satisfy credentials:'include' on the panel's fetches and EventSource(..., {withCredentials:true}). * terminal-agent.ts — add the same Origin + Credentials headers to the /claude-available response. WS upgrade keeps Sec-WebSocket-Protocol auth and doesn't need CORS; /internal/* stays loopback + bearer-auth. * server-auth.test.ts, terminal-agent.test.ts — source-pattern tests matching the existing /refs and /activity/history CORS-test style. Verify the middleware exists, gates on chrome-extension://, sets both headers, and is never wildcarded. Verification curl (post-patch): $ curl -H "Origin: chrome-extension://abc" -D - http://127.0.0.1:34567/health HTTP/1.1 200 OK Access-Control-Allow-Origin: chrome-extension://abc Access-Control-Allow-Credentials: true $ curl -H "Origin: https://evil.com" -D - http://127.0.0.1:34567/health HTTP/1.1 200 OK (no Access-Control-* headers — non-extension origins still blocked) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
026751ea20
commit
e268718351
|
|
@ -1323,8 +1323,41 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||||
// `authToken` (the cfg-derived value) explicitly.
|
// `authToken` (the cfg-derived value) explicitly.
|
||||||
const browserManager = cfgBrowserManager;
|
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<Response>,
|
||||||
|
) => async (req: Request): Promise<Response> => {
|
||||||
|
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<Response> => {
|
const makeFetchHandler = (surface: Surface) => withCors(async (req: Request): Promise<Response> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
// ─── Tunnel surface filter (runs before any route dispatch) ──
|
// ─── Tunnel surface filter (runs before any route dispatch) ──
|
||||||
|
|
@ -2287,7 +2320,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
};
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchLocal: makeFetchHandler('local'),
|
fetchLocal: makeFetchHandler('local'),
|
||||||
|
|
|
||||||
|
|
@ -240,9 +240,18 @@ function buildServer() {
|
||||||
if (url.pathname === '/claude-available' && req.method === 'GET') {
|
if (url.pathname === '/claude-available' && req.method === 'GET') {
|
||||||
writeClaudeAvailable();
|
writeClaudeAvailable();
|
||||||
const found = findClaude();
|
const found = findClaude();
|
||||||
|
const origin = req.headers.get('origin');
|
||||||
return new Response(JSON.stringify({ available: !!found, path: found }), {
|
return new Response(JSON.stringify({ available: !!found, path: found }), {
|
||||||
status: 200,
|
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',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,28 @@ describe('Server auth security', () => {
|
||||||
expect(refsBlock).not.toContain("'*'");
|
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 4: /activity/history requires auth via validateAuth
|
||||||
test('/activity/history requires authentication', () => {
|
test('/activity/history requires authentication', () => {
|
||||||
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Sidebar endpoints');
|
const historyBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/history'", 'Sidebar endpoints');
|
||||||
|
|
|
||||||
|
|
@ -221,3 +221,19 @@ describe('Source-level guard: server.ts /pty-session route', () => {
|
||||||
expect(route).toContain('buildPtySetCookie');
|
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': '*'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue