diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 20e4fb5..81f6b9e 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -44,7 +44,10 @@ export async function isDaemonRunning(): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); - const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal }); + const res = await fetch(`${DAEMON_URL}/status`, { + headers: { 'X-OpenCLI': '1' }, + signal: controller.signal, + }); clearTimeout(timer); return res.ok; } catch { @@ -59,7 +62,10 @@ export async function isExtensionConnected(): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); - const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal }); + const res = await fetch(`${DAEMON_URL}/status`, { + headers: { 'X-OpenCLI': '1' }, + signal: controller.signal, + }); clearTimeout(timer); if (!res.ok) return false; const data = await res.json() as { extensionConnected?: boolean }; @@ -90,7 +96,7 @@ export async function sendCommand( const res = await fetch(`${DAEMON_URL}/command`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' }, body: JSON.stringify(command), signal: controller.signal, }); diff --git a/src/browser/discover.ts b/src/browser/discover.ts index ea269c7..ec9263b 100644 --- a/src/browser/discover.ts +++ b/src/browser/discover.ts @@ -18,7 +18,9 @@ export async function checkDaemonStatus(): Promise<{ }> { try { const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10); - const res = await fetch(`http://127.0.0.1:${port}/status`); + const res = await fetch(`http://127.0.0.1:${port}/status`, { + headers: { 'X-OpenCLI': '1' }, + }); const data = await res.json() as { ok: boolean; extensionConnected: boolean }; return { running: true, extensionConnected: data.extensionConnected }; } catch { diff --git a/src/daemon.ts b/src/daemon.ts index 8423d06..c5cc123 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,6 +5,14 @@ * CLI → HTTP POST /command → daemon → WebSocket → Extension * Extension → WebSocket result → daemon → HTTP response → CLI * + * Security (defense-in-depth against browser-based CSRF): + * 1. Origin check — reject HTTP/WS from non chrome-extension:// origins + * 2. Custom header — require X-OpenCLI header (browsers can't send it + * without CORS preflight, which we deny) + * 3. No CORS headers — responses never include Access-Control-Allow-Origin + * 4. Body size limit — 1 MB max to prevent OOM + * 5. WebSocket verifyClient — reject upgrade before connection is established + * * Lifecycle: * - Auto-spawned by opencli on first browser command * - Auto-exits after 5 minutes of idle @@ -49,25 +57,56 @@ function resetIdleTimer(): void { // ─── HTTP Server ───────────────────────────────────────────────────── +const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; - req.on('data', (c: Buffer) => chunks.push(c)); + let size = 0; + req.on('data', (c: Buffer) => { + size += c.length; + if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return; } + chunks.push(c); + }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); req.on('error', reject); }); } function jsonResponse(res: ServerResponse, status: number, data: unknown): void { - res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); + res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + // ─── Security: Origin & custom-header check ────────────────────── + // Block browser-based CSRF: browsers always send an Origin header on + // cross-origin requests. Node.js CLI fetch does NOT send Origin, so + // legitimate CLI requests pass through. Chrome Extension connects via + // WebSocket (which bypasses this HTTP handler entirely). + const origin = req.headers['origin'] as string | undefined; + if (origin && !origin.startsWith('chrome-extension://')) { + jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' }); + return; + } + + // CORS: do NOT send Access-Control-Allow-Origin for normal requests. + // Only handle preflight so browsers get a definitive "no" answer. + if (req.method === 'OPTIONS') { + // No ACAO header → browser will block the actual request. + res.writeHead(204); + res.end(); + return; + } + + // Require custom header on all HTTP requests. Browsers cannot attach + // custom headers in "simple" requests, and our preflight returns no + // Access-Control-Allow-Headers, so scripted fetch() from web pages is + // blocked even if Origin check is somehow bypassed. + if (!req.headers['x-opencli']) { + jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' }); + return; + } const url = req.url ?? '/'; const pathname = url.split('?')[0]; @@ -136,7 +175,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise // ─── WebSocket for Extension ───────────────────────────────────────── const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); }); -const wss = new WebSocketServer({ server: httpServer, path: '/ext' }); +const wss = new WebSocketServer({ + server: httpServer, + path: '/ext', + verifyClient: ({ req }: { req: IncomingMessage }) => { + // Block browser-originated WebSocket connections. Browsers don't + // enforce CORS on WebSocket, so a malicious webpage could connect to + // ws://localhost:19825/ext and impersonate the Extension. Real Chrome + // Extensions send origin chrome-extension://. + const origin = req.headers['origin'] as string | undefined; + return !origin || origin.startsWith('chrome-extension://'); + }, +}); wss.on('connection', (ws: WebSocket) => { console.error('[daemon] Extension connected');