Skip to content

Commit 40bd11d

Browse files
authored
fix(daemon): harden security against browser CSRF attacks (#268) (#270)
- Add Origin header check: reject HTTP/WS from non chrome-extension:// origins - Require X-OpenCLI custom header on all HTTP requests - Remove Access-Control-Allow-Origin: * from all responses - Add WebSocket verifyClient to reject malicious connections at upgrade - Add 1MB body size limit to prevent OOM - Update file header with security model documentation Closes #268
1 parent 9a77dba commit 40bd11d

File tree

3 files changed

+69
-11
lines changed

3 files changed

+69
-11
lines changed

src/browser/daemon-client.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ export async function isDaemonRunning(): Promise<boolean> {
4444
try {
4545
const controller = new AbortController();
4646
const timer = setTimeout(() => controller.abort(), 2000);
47-
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
47+
const res = await fetch(`${DAEMON_URL}/status`, {
48+
headers: { 'X-OpenCLI': '1' },
49+
signal: controller.signal,
50+
});
4851
clearTimeout(timer);
4952
return res.ok;
5053
} catch {
@@ -59,7 +62,10 @@ export async function isExtensionConnected(): Promise<boolean> {
5962
try {
6063
const controller = new AbortController();
6164
const timer = setTimeout(() => controller.abort(), 2000);
62-
const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
65+
const res = await fetch(`${DAEMON_URL}/status`, {
66+
headers: { 'X-OpenCLI': '1' },
67+
signal: controller.signal,
68+
});
6369
clearTimeout(timer);
6470
if (!res.ok) return false;
6571
const data = await res.json() as { extensionConnected?: boolean };
@@ -90,7 +96,7 @@ export async function sendCommand(
9096

9197
const res = await fetch(`${DAEMON_URL}/command`, {
9298
method: 'POST',
93-
headers: { 'Content-Type': 'application/json' },
99+
headers: { 'Content-Type': 'application/json', 'X-OpenCLI': '1' },
94100
body: JSON.stringify(command),
95101
signal: controller.signal,
96102
});

src/browser/discover.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export async function checkDaemonStatus(): Promise<{
1818
}> {
1919
try {
2020
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
21-
const res = await fetch(`http://127.0.0.1:${port}/status`);
21+
const res = await fetch(`http://127.0.0.1:${port}/status`, {
22+
headers: { 'X-OpenCLI': '1' },
23+
});
2224
const data = await res.json() as { ok: boolean; extensionConnected: boolean };
2325
return { running: true, extensionConnected: data.extensionConnected };
2426
} catch {

src/daemon.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
* CLI → HTTP POST /command → daemon → WebSocket → Extension
66
* Extension → WebSocket result → daemon → HTTP response → CLI
77
*
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+
*
816
* Lifecycle:
917
* - Auto-spawned by opencli on first browser command
1018
* - Auto-exits after 5 minutes of idle
@@ -49,25 +57,56 @@ function resetIdleTimer(): void {
4957

5058
// ─── HTTP Server ─────────────────────────────────────────────────────
5159

60+
const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM
61+
5262
function readBody(req: IncomingMessage): Promise<string> {
5363
return new Promise((resolve, reject) => {
5464
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+
});
5671
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
5772
req.on('error', reject);
5873
});
5974
}
6075

6176
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' });
6378
res.end(JSON.stringify(data));
6479
}
6580

6681
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+
}
71110

72111
const url = req.url ?? '/';
73112
const pathname = url.split('?')[0];
@@ -136,7 +175,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
136175
// ─── WebSocket for Extension ─────────────────────────────────────────
137176

138177
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+
});
140190

141191
wss.on('connection', (ws: WebSocket) => {
142192
console.error('[daemon] Extension connected');

0 commit comments

Comments
 (0)