From f862e2f7dfa6da5312fae3d74fe49d2669c1aa4b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Jan 2026 00:42:00 -0600 Subject: [PATCH] Security: Reject cross-origin connections to daemon and stream server (#274) --- src/daemon.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/daemon.ts | 13 +++++++++++++ src/stream-server.ts | 20 +++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/daemon.test.ts b/src/daemon.test.ts index 8825b65..62ef417 100644 --- a/src/daemon.test.ts +++ b/src/daemon.test.ts @@ -3,6 +3,42 @@ import * as os from 'os'; import * as path from 'path'; import { getSocketDir } from './daemon.js'; +/** + * HTTP request detection pattern used in daemon.ts to prevent cross-origin attacks. + * This pattern detects HTTP method prefixes that browsers must send when using fetch(). + */ +const HTTP_REQUEST_PATTERN = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i; + +describe('HTTP request detection (security)', () => { + it('should detect POST requests from fetch()', () => { + const httpRequest = 'POST / HTTP/1.1\r\nHost: 127.0.0.1:51234\r\n'; + expect(HTTP_REQUEST_PATTERN.test(httpRequest.trimStart())).toBe(true); + }); + + it('should detect GET requests', () => { + expect(HTTP_REQUEST_PATTERN.test('GET / HTTP/1.1')).toBe(true); + }); + + it('should detect OPTIONS preflight requests', () => { + expect(HTTP_REQUEST_PATTERN.test('OPTIONS / HTTP/1.1')).toBe(true); + }); + + it('should NOT detect valid JSON commands', () => { + const jsonCommand = '{"id":"1","action":"navigate","url":"https://example.com"}'; + expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false); + }); + + it('should NOT detect JSON with leading whitespace', () => { + const jsonCommand = ' {"id":"1","action":"click","selector":"button"}'; + expect(HTTP_REQUEST_PATTERN.test(jsonCommand.trimStart())).toBe(false); + }); + + it('should be case insensitive for HTTP methods', () => { + expect(HTTP_REQUEST_PATTERN.test('post / HTTP/1.1')).toBe(true); + expect(HTTP_REQUEST_PATTERN.test('Post / HTTP/1.1')).toBe(true); + }); +}); + describe('getSocketDir', () => { const originalEnv = { ...process.env }; diff --git a/src/daemon.ts b/src/daemon.ts index 7447261..3a3b1a4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -196,10 +196,23 @@ export async function startDaemon(options?: { streamPort?: number }): Promise { let buffer = ''; + let httpChecked = false; socket.on('data', async (data) => { buffer += data.toString(); + // Security: Detect and reject HTTP requests to prevent cross-origin attacks. + // Browsers using fetch() must send HTTP headers (e.g., "POST / HTTP/1.1"), + // while legitimate clients send raw JSON starting with "{". + if (!httpChecked) { + httpChecked = true; + const trimmed = buffer.trimStart(); + if (/^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i.test(trimmed)) { + socket.destroy(); + return; + } + } + // Process complete lines while (buffer.includes('\n')) { const newlineIdx = buffer.indexOf('\n'); diff --git a/src/stream-server.ts b/src/stream-server.ts index 002d3cc..83136d6 100644 --- a/src/stream-server.ts +++ b/src/stream-server.ts @@ -87,7 +87,25 @@ export class StreamServer { start(): Promise { return new Promise((resolve, reject) => { try { - this.wss = new WebSocketServer({ port: this.port }); + this.wss = new WebSocketServer({ + port: this.port, + // Security: Reject cross-origin WebSocket connections from browsers. + // This prevents malicious web pages from connecting and injecting input events. + verifyClient: (info: { + origin: string; + secure: boolean; + req: import('http').IncomingMessage; + }) => { + const origin = info.origin; + // Allow connections with no origin (non-browser clients like CLI tools) + // Reject connections from web pages (which always have an origin) + if (origin && !origin.startsWith('file://')) { + console.log(`[StreamServer] Rejected connection from origin: ${origin}`); + return false; + } + return true; + }, + }); this.wss.on('connection', (ws) => { this.handleConnection(ws);