diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 42c0277a96..f5fe638253 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -60,7 +60,7 @@ if (first && SUBCOMMAND_MAP[first]) { // Default: daemon mode. let port = Number(process.env.OD_PORT) || 7456; -let host = process.env.OD_BIND_HOST || '0.0.0.0'; +let host = process.env.OD_BIND_HOST || '127.0.0.1'; let open = true; for (let i = 0; i < argv.length; i++) { @@ -101,7 +101,7 @@ function printRootHelp() { Options: --port Port to listen on (default: 7456, env: OD_PORT). - --host Interface address to bind to (default: 0.0.0.0, env: OD_BIND_HOST). + --host Interface address to bind to (default: 127.0.0.1, env: OD_BIND_HOST). Set to a specific IP (e.g. a Tailscale address) to restrict access to that interface only. --no-open Do not open the browser after start. diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 70b80dd96e..a661f72873 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -609,10 +609,66 @@ export function createSseResponse( }; } -export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '0.0.0.0', returnServer = false } = {}) { +export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) { let resolvedPort = port; const app = express(); app.use(express.json({ limit: '4mb' })); + + // Build the set of allowed browser origins for the current bind config. + // Shared by the global origin middleware and isLocalSameOrigin() so + // both use the same policy (loopback + explicit bind host, HTTP + HTTPS, + // OD_WEB_PORT support). + function buildAllowedOrigins() { + const ports = [resolvedPort]; + const webPort = Number(process.env.OD_WEB_PORT); + if (webPort && webPort !== resolvedPort) ports.push(webPort); + const schemes = ['http', 'https']; + const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; + return new Set( + ports.flatMap((p) => [ + ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), + // When bound to a specific non-loopback address (e.g. Tailscale, + // LAN IP, or 0.0.0.0), allow browser requests from that address + // too so the documented --host escape hatch remains usable. + ...schemes.map((s) => `${s}://${host}:${p}`), + ]), + ); + } + + // Routes that serve content to sandboxed iframes (Origin: null) for + // read-only purposes. All other /api routes reject Origin: null. + const _NULL_ORIGIN_SAFE_GET_RE = + /^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; + + // Reject cross-origin requests to API endpoints. + // Health/version remain open for monitoring probes. + // Non-browser clients (no Origin header) are always allowed. + app.use('/api', (req, res, next) => { + const origin = req.headers.origin; + // Non-browser client → allow. + if (origin == null || origin === '') return next(); + + // Origin: null (sandboxed iframes). Only allowed for safe, read-only + // routes that set their own CORS headers for canvas drawing. + if (origin === 'null') { + const isSafeReadOnly = + req.method === 'GET' && _NULL_ORIGIN_SAFE_GET_RE.test(req.path); + if (!isSafeReadOnly) { + return res.status(403).json({ error: 'Origin: null not allowed for this route' }); + } + return next(); + } + + // Fail-closed: block all browser origins until port is resolved. + if (!resolvedPort) { + return res.status(403).json({ error: 'Server initializing' }); + } + + if (!buildAllowedOrigins().has(String(origin))) { + return res.status(403).json({ error: 'Cross-origin requests are not allowed' }); + } + next(); + }); const db = openDatabase(PROJECT_ROOT, { dataDir: RUNTIME_DATA_DIR }); if (process.env.OD_CODEX_DISABLE_PLUGINS === '1') { @@ -2832,23 +2888,38 @@ function assembleExample(templateHtml, slidesHtml, title) { } export function isLocalSameOrigin(req, port) { + // Accepts http + https, loopback hosts, OD_WEB_PORT, and the explicit + // bind host — matching the global origin middleware policy exactly. + const host = String(req.headers.host || ''); + const origin = req.headers.origin; + + // Build allowed set inline (same logic as buildAllowedOrigins in + // startServer, but self-contained so the exported helper works + // without closing over server-scoped variables). const ports = [port]; const webPort = Number(process.env.OD_WEB_PORT); if (webPort && webPort !== port) ports.push(webPort); - + const bindHost = process.env.OD_BIND_HOST || '127.0.0.1'; + const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; const allowedHosts = new Set( - ports.flatMap((p) => [`127.0.0.1:${p}`, `localhost:${p}`, `[::1]:${p}`]), - ); - const allowedOrigins = new Set( ports.flatMap((p) => [ - `http://127.0.0.1:${p}`, - `http://localhost:${p}`, - `http://[::1]:${p}`, + ...loopbackHosts.map((h) => `${h}:${p}`), + `${bindHost}:${p}`, ]), ); - const host = String(req.headers.host || ''); + + // Reject unknown Host first (DNS rebinding / Host header attack) if (!allowedHosts.has(host)) return false; - const origin = req.headers.origin; + + // Non-browser client with valid Host → allow if (origin == null || origin === '') return true; + + const schemes = ['http', 'https']; + const allowedOrigins = new Set( + ports.flatMap((p) => [ + ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), + ...schemes.map((s) => `${s}://${bindHost}:${p}`), + ]), + ); return allowedOrigins.has(String(origin)); } diff --git a/apps/daemon/tests/origin-validation.test.ts b/apps/daemon/tests/origin-validation.test.ts new file mode 100644 index 0000000000..8fcd0138d0 --- /dev/null +++ b/apps/daemon/tests/origin-validation.test.ts @@ -0,0 +1,324 @@ +// @ts-nocheck +import http from 'node:http'; +import express from 'express'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * Replicate the origin validation middleware from server.ts exactly + * as it appears in the real daemon, so we test the actual logic + * including OD_WEB_PORT, Origin: null scoping, and non-loopback host. + */ +function createOriginMiddleware(resolvedPort, host = '127.0.0.1') { + // Routes that serve content to sandboxed iframes (Origin: null) for + // read-only purposes. + const _NULL_ORIGIN_SAFE_GET_RE = + /^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; + return (req, res, next) => { + const origin = req.headers.origin; + if (origin == null || origin === '') return next(); + if (origin === 'null') { + const isSafeReadOnly = + req.method === 'GET' && _NULL_ORIGIN_SAFE_GET_RE.test(req.path); + if (!isSafeReadOnly) { + return res.status(403).json({ error: 'Origin: null not allowed for this route' }); + } + return next(); + } + if (!resolvedPort) { + return res.status(403).json({ error: 'Server initializing' }); + } + const ports = [resolvedPort]; + const webPort = Number(process.env.OD_WEB_PORT); + if (webPort && webPort !== resolvedPort) ports.push(webPort); + const schemes = ['http', 'https']; + const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]']; + const allowedOrigins = new Set( + ports.flatMap((p) => [ + ...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)), + ...schemes.map((s) => `${s}://${host}:${p}`), + ]), + ); + if (!allowedOrigins.has(String(origin))) { + return res.status(403).json({ error: 'Cross-origin requests are not allowed' }); + } + next(); + }; +} + +function makeTestApp(port, host = '127.0.0.1') { + const app = express(); + app.use(express.json()); + app.use('/api', createOriginMiddleware(port, host)); + app.get('/api/health', (_req, res) => res.json({ ok: true })); + app.get('/api/projects', (_req, res) => res.json({ projects: [] })); + app.get('/api/projects/:id/raw/:name', (req, res) => { + // Mimics the real raw-file route that sets CORS for Origin: null + if (req.headers.origin === 'null') { + res.header('Access-Control-Allow-Origin', '*'); + } + res.json({ file: req.params.name }); + }); + app.post('/api/projects', (req, res) => res.json({ project: req.body })); + app.delete('/api/projects/:id', (req, res) => res.json({ ok: true })); + app.get('/api/codex-pets/:id/spritesheet', (req, res) => { + // Mimics the real spritesheet route that sets CORS for Origin: null + if (req.headers.origin === 'null') { + res.header('Access-Control-Allow-Origin', 'null'); + } + res.type('image/png').send(Buffer.from('fake-sprite')); + }); + return app; +} + +function request(port, method, path, { origin, headers = {} } = {}) { + return new Promise((resolve) => { + const opts = { + hostname: '127.0.0.1', + port, + path, + method, + headers: { + ...headers, + ...(origin !== undefined ? { origin } : {}), + }, + }; + const req = http.request(opts, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers })); + }); + req.end(); + }); +} + +describe('daemon origin validation middleware', () => { + let server; + let port; + + beforeAll( + () => + new Promise((resolve) => { + // Start on port 0 to get a dynamic port, then rebuild with real port + const tempApp = makeTestApp(0); + const tempServer = tempApp.listen(0, '127.0.0.1', () => { + port = tempServer.address().port; + tempServer.close(() => { + const realApp = makeTestApp(port); + server = realApp.listen(port, '127.0.0.1', () => resolve()); + }); + }); + }), + ); + + afterAll( + () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ); + + // --- Non-browser clients (no Origin) --- + + it('allows requests without Origin header (curl, CLI)', async () => { + const res = await request(port, 'GET', '/api/health'); + expect(res.status).toBe(200); + }); + + // --- Same-origin (localhost) --- + + it('allows same-origin requests from http://127.0.0.1', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://127.0.0.1:${port}`, + }); + expect(res.status).toBe(200); + }); + + it('allows same-origin requests from http://localhost', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://localhost:${port}`, + }); + expect(res.status).toBe(200); + }); + + it('allows same-origin requests via HTTPS', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `https://127.0.0.1:${port}`, + }); + expect(res.status).toBe(200); + }); + + // --- Origin: null (sandboxed iframe previews) --- + + it('allows Origin: null for GET raw-file preview routes', async () => { + const res = await request(port, 'GET', '/api/projects/abc/raw/design.html', { + origin: 'null', + }); + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('*'); + }); + + it('allows Origin: null for GET codex-pet spritesheet routes', async () => { + const res = await request(port, 'GET', '/api/codex-pets/my-pet/spritesheet', { + origin: 'null', + }); + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('null'); + }); + + it('rejects Origin: null on POST to state-changing endpoints', async () => { + const res = await request(port, 'POST', '/api/projects', { + origin: 'null', + headers: { 'content-type': 'application/json' }, + }); + expect(res.status).toBe(403); + expect(JSON.parse(res.body)).toEqual({ error: 'Origin: null not allowed for this route' }); + }); + + it('rejects Origin: null on DELETE endpoints', async () => { + const res = await request(port, 'DELETE', '/api/projects/abc', { + origin: 'null', + }); + expect(res.status).toBe(403); + }); + + it('rejects Origin: null on non-raw-file GET routes', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: 'null', + }); + expect(res.status).toBe(403); + }); + + // --- Cross-origin rejection --- + + it('blocks cross-origin requests from external domains', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: 'http://evil.com', + }); + expect(res.status).toBe(403); + expect(JSON.parse(res.body)).toEqual({ error: 'Cross-origin requests are not allowed' }); + }); + + it('blocks cross-origin requests from other local ports', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://127.0.0.1:9999`, + }); + expect(res.status).toBe(403); + }); + + it('blocks cross-origin POST to state-changing endpoints', async () => { + const res = await request(port, 'POST', '/api/projects', { + origin: 'http://attacker.local', + headers: { 'content-type': 'application/json' }, + }); + expect(res.status).toBe(403); + }); + + // --- OD_WEB_PORT (split-port proxy) --- + + it('allows requests from OD_WEB_PORT (web proxy port)', async () => { + const webPort = port + 1000; + process.env.OD_WEB_PORT = String(webPort); + const res = await request(port, 'GET', '/api/projects', { + origin: `http://127.0.0.1:${webPort}`, + }); + delete process.env.OD_WEB_PORT; + expect(res.status).toBe(200); + }); + + it('blocks requests from unknown ports even with OD_WEB_PORT set', async () => { + const webPort = port + 1000; + process.env.OD_WEB_PORT = String(webPort); + const res = await request(port, 'GET', '/api/projects', { + origin: `http://127.0.0.1:${port + 2000}`, + }); + delete process.env.OD_WEB_PORT; + expect(res.status).toBe(403); + }); + + // Note: fail-closed coverage when port=0 is tested in the dedicated + // describe block below ("fail-closed before port resolution"). +}); + +describe('origin validation: fail-closed before port resolution', () => { + let server; + let port; + + beforeAll( + () => + new Promise((resolve) => { + const app = makeTestApp(0); // port=0 → not resolved + server = app.listen(0, '127.0.0.1', () => { + port = server.address().port; + resolve(); + }); + }), + ); + + afterAll( + () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ); + + it('blocks browser origins when port is not resolved (fail-closed)', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://127.0.0.1:${port}`, + }); + expect(res.status).toBe(403); + }); + + it('still allows non-browser clients when port is not resolved', async () => { + const res = await request(port, 'GET', '/api/health'); + expect(res.status).toBe(200); + }); +}); + +describe('origin validation: non-loopback bind host', () => { + let server; + let port; + const nonLoopbackHost = '100.64.1.2'; // Tailscale-like address + + beforeAll( + () => + new Promise((resolve) => { + // Start on port 0 to get a dynamic port, then rebuild with real port + const tempApp = makeTestApp(0, nonLoopbackHost); + const tempServer = tempApp.listen(0, '127.0.0.1', () => { + port = tempServer.address().port; + tempServer.close(() => { + const realApp = makeTestApp(port, nonLoopbackHost); + server = realApp.listen(port, '127.0.0.1', () => resolve()); + }); + }); + }), + ); + + afterAll( + () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ); + + it('allows browser requests from the non-loopback bind host', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://${nonLoopbackHost}:${port}`, + }); + expect(res.status).toBe(200); + }); + + it('still allows localhost origins alongside non-loopback host', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://127.0.0.1:${port}`, + }); + expect(res.status).toBe(200); + }); + + it('blocks unknown external origins even with non-loopback host', async () => { + const res = await request(port, 'GET', '/api/projects', { + origin: `http://evil.com:${port}`, + }); + expect(res.status).toBe(403); + }); +});