|
5 | 5 | * CLI → HTTP POST /command → daemon → WebSocket → Extension |
6 | 6 | * Extension → WebSocket result → daemon → HTTP response → CLI |
7 | 7 | * |
| 8 | + * Security (defense-in-depth against browser-based CSRF): |
| 9 | + * 1. Origin check — reject HTTP/WS from non chrome-extension:// origins |
| 10 | + * 2. Custom header — require X-OpenCLI header (browsers can't send it |
| 11 | + * without CORS preflight, which we deny) |
| 12 | + * 3. No CORS headers — responses never include Access-Control-Allow-Origin |
| 13 | + * 4. Body size limit — 1 MB max to prevent OOM |
| 14 | + * 5. WebSocket verifyClient — reject upgrade before connection is established |
| 15 | + * |
8 | 16 | * Lifecycle: |
9 | 17 | * - Auto-spawned by opencli on first browser command |
10 | 18 | * - Auto-exits after 5 minutes of idle |
@@ -49,25 +57,56 @@ function resetIdleTimer(): void { |
49 | 57 |
|
50 | 58 | // ─── HTTP Server ───────────────────────────────────────────────────── |
51 | 59 |
|
| 60 | +const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM |
| 61 | + |
52 | 62 | function readBody(req: IncomingMessage): Promise<string> { |
53 | 63 | return new Promise((resolve, reject) => { |
54 | 64 | const chunks: Buffer[] = []; |
55 | | - req.on('data', (c: Buffer) => chunks.push(c)); |
| 65 | + let size = 0; |
| 66 | + req.on('data', (c: Buffer) => { |
| 67 | + size += c.length; |
| 68 | + if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return; } |
| 69 | + chunks.push(c); |
| 70 | + }); |
56 | 71 | req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); |
57 | 72 | req.on('error', reject); |
58 | 73 | }); |
59 | 74 | } |
60 | 75 |
|
61 | 76 | function jsonResponse(res: ServerResponse, status: number, data: unknown): void { |
62 | | - res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); |
| 77 | + res.writeHead(status, { 'Content-Type': 'application/json' }); |
63 | 78 | res.end(JSON.stringify(data)); |
64 | 79 | } |
65 | 80 |
|
66 | 81 | async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> { |
67 | | - res.setHeader('Access-Control-Allow-Origin', '*'); |
68 | | - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); |
69 | | - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); |
70 | | - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } |
| 82 | + // ─── Security: Origin & custom-header check ────────────────────── |
| 83 | + // Block browser-based CSRF: browsers always send an Origin header on |
| 84 | + // cross-origin requests. Node.js CLI fetch does NOT send Origin, so |
| 85 | + // legitimate CLI requests pass through. Chrome Extension connects via |
| 86 | + // WebSocket (which bypasses this HTTP handler entirely). |
| 87 | + const origin = req.headers['origin'] as string | undefined; |
| 88 | + if (origin && !origin.startsWith('chrome-extension://')) { |
| 89 | + jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' }); |
| 90 | + return; |
| 91 | + } |
| 92 | + |
| 93 | + // CORS: do NOT send Access-Control-Allow-Origin for normal requests. |
| 94 | + // Only handle preflight so browsers get a definitive "no" answer. |
| 95 | + if (req.method === 'OPTIONS') { |
| 96 | + // No ACAO header → browser will block the actual request. |
| 97 | + res.writeHead(204); |
| 98 | + res.end(); |
| 99 | + return; |
| 100 | + } |
| 101 | + |
| 102 | + // Require custom header on all HTTP requests. Browsers cannot attach |
| 103 | + // custom headers in "simple" requests, and our preflight returns no |
| 104 | + // Access-Control-Allow-Headers, so scripted fetch() from web pages is |
| 105 | + // blocked even if Origin check is somehow bypassed. |
| 106 | + if (!req.headers['x-opencli']) { |
| 107 | + jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' }); |
| 108 | + return; |
| 109 | + } |
71 | 110 |
|
72 | 111 | const url = req.url ?? '/'; |
73 | 112 | const pathname = url.split('?')[0]; |
@@ -136,7 +175,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise |
136 | 175 | // ─── WebSocket for Extension ───────────────────────────────────────── |
137 | 176 |
|
138 | 177 | const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); }); |
139 | | -const wss = new WebSocketServer({ server: httpServer, path: '/ext' }); |
| 178 | +const wss = new WebSocketServer({ |
| 179 | + server: httpServer, |
| 180 | + path: '/ext', |
| 181 | + verifyClient: ({ req }: { req: IncomingMessage }) => { |
| 182 | + // Block browser-originated WebSocket connections. Browsers don't |
| 183 | + // enforce CORS on WebSocket, so a malicious webpage could connect to |
| 184 | + // ws://localhost:19825/ext and impersonate the Extension. Real Chrome |
| 185 | + // Extensions send origin chrome-extension://<id>. |
| 186 | + const origin = req.headers['origin'] as string | undefined; |
| 187 | + return !origin || origin.startsWith('chrome-extension://'); |
| 188 | + }, |
| 189 | +}); |
140 | 190 |
|
141 | 191 | wss.on('connection', (ws: WebSocket) => { |
142 | 192 | console.error('[daemon] Extension connected'); |
|
0 commit comments