diff --git a/src/origin-guard.js b/src/origin-guard.js new file mode 100644 index 0000000..0c1007c --- /dev/null +++ b/src/origin-guard.js @@ -0,0 +1,50 @@ +'use strict'; + +// Shared same-origin / CSRF logic used by BOTH the HTTP API (server.js) and the +// browser-terminal WebSocket upgrade (terminal.js), so the two can never drift. +// +// A browser attaches `Origin` to cross-origin requests — and to same-origin +// POST/PUT/DELETE in modern engines — so when it is present it must match the +// host we are serving from. `Referer` is a fallback for the rare client/proxy +// that strips `Origin` but keeps `Referer` (OWASP CSRF cheat sheet). Absent +// BOTH headers = a non-browser client (curl, scripts, the desktop shell's +// same-origin fetch) → allowed; a browser CSRF attack always carries at least +// one of the two on a mutating cross-site request. Comparison is host+port only +// (scheme intentionally ignored — codbash serves plain HTTP on loopback/LAN), +// case-normalized because RFC 7230 makes Host case-insensitive. + +function _hostOf(value) { + try { return new URL(value).host.toLowerCase(); } catch (_e) { return null; } +} + +// Classify a request by its Origin/Referer vs Host. Returns +// { crossOrigin: boolean, reason: string|null }. `reason` is set only when +// crossOrigin is true, for logging: 'bad origin' | 'origin mismatch' | +// 'bad referer' | 'referer mismatch'. +function checkSameOrigin(headers) { + const host = String((headers && headers.host) || '').toLowerCase(); + const origin = headers && headers.origin; + if (origin) { + const h = _hostOf(origin); + if (h === null) return { crossOrigin: true, reason: 'bad origin' }; + return h === host ? { crossOrigin: false, reason: null } + : { crossOrigin: true, reason: 'origin mismatch' }; + } + const referer = headers && headers.referer; + if (referer) { + const h = _hostOf(referer); + if (h === null) return { crossOrigin: true, reason: 'bad referer' }; + return h === host ? { crossOrigin: false, reason: null } + : { crossOrigin: true, reason: 'referer mismatch' }; + } + return { crossOrigin: false, reason: null }; +} + +// HTTP CSRF gate: only state-changing methods are relevant. Returns true when +// the request must be rejected (403). +function isDisallowedCrossOrigin(method, headers) { + if (method !== 'POST' && method !== 'PUT' && method !== 'DELETE') return false; + return checkSameOrigin(headers).crossOrigin; +} + +module.exports = { checkSameOrigin, isDisallowedCrossOrigin }; diff --git a/src/server.js b/src/server.js index f8710db..693517c 100644 --- a/src/server.js +++ b/src/server.js @@ -12,6 +12,7 @@ const { convertSession } = require('./convert'); const { generateHandoff } = require('./handoff'); const { CHANGELOG } = require('./changelog'); const { getHTML } = require('./html'); +const { isDisallowedCrossOrigin } = require('./origin-guard'); const projectsApi = require('./projects'); const settingsApi = require('./settings'); const terminal = require('./terminal'); @@ -151,6 +152,14 @@ function startServer(host, port, openBrowser = true) { return; } } + // CSRF gate: block cross-origin state-changing requests before any route + // runs (covers every current and future POST/PUT/DELETE uniformly). Static + // GETs and the same-origin frontend are unaffected. See isDisallowedCrossOrigin. + if (isDisallowedCrossOrigin(req.method, req.headers)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden: cross-origin request'); + return; + } // req.url is usually relative, so this base is only for URL parsing. // Keep it stable instead of reusing the bind host, which may be a wildcard listen address. const parsed = new URL(req.url, `http://localhost:${port}`); diff --git a/src/terminal.js b/src/terminal.js index 4b5d800..ec0fc6f 100644 --- a/src/terminal.js +++ b/src/terminal.js @@ -19,6 +19,7 @@ const crypto = require('crypto'); const os = require('os'); const fs = require('fs'); const ptyRegistry = require('./pty-registry'); +const { checkSameOrigin } = require('./origin-guard'); const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const WS_PATH = '/ws/terminal'; @@ -178,14 +179,10 @@ function verifyUpgradeAuth(req, url) { return { ok: false, reason: 'bad token' }; } // Reject cross-origin upgrades (defense against a malicious page in the - // browser reaching the loopback socket). Origin, when present, must be the - // same host we are serving from. - const origin = req.headers.origin; - if (origin) { - let originHost; - try { originHost = new URL(origin).host; } catch (_e) { return { ok: false, reason: 'bad origin' }; } - if (originHost !== req.headers.host) return { ok: false, reason: 'origin mismatch' }; - } + // browser reaching the loopback socket). Shared with the HTTP CSRF gate via + // origin-guard so the two never drift. + const oc = checkSameOrigin(req.headers); + if (oc.crossOrigin) return { ok: false, reason: oc.reason }; return { ok: true }; } diff --git a/test/csrf-origin-guard.test.js b/test/csrf-origin-guard.test.js new file mode 100644 index 0000000..72335a6 --- /dev/null +++ b/test/csrf-origin-guard.test.js @@ -0,0 +1,89 @@ +'use strict'; + +// CSRF defense for state-changing API calls. A browser sends `Origin` on every +// cross-origin request (and on same-origin POST/PUT/DELETE in modern engines), +// so when Origin is present it must match our own Host. Absent Origin = a +// non-browser client (curl, the desktop shell's same-origin fetch) → allowed; +// CSRF needs a browser, which always sends Origin on mutating requests. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { isDisallowedCrossOrigin, checkSameOrigin } = require('../src/origin-guard.js'); + +const H = (origin, host) => ({ origin, host }); + +test('cross-origin POST (Origin host ≠ Host) is blocked', () => { + assert.equal(isDisallowedCrossOrigin('POST', H('https://evil.example', 'localhost:8842')), true); +}); + +test('same-origin POST (Origin host === Host) is allowed', () => { + assert.equal(isDisallowedCrossOrigin('POST', H('http://localhost:8842', 'localhost:8842')), false); +}); + +test('POST with no Origin header is allowed (non-browser client)', () => { + assert.equal(isDisallowedCrossOrigin('POST', H(undefined, 'localhost:8842')), false); +}); + +test('opaque "Origin: null" mutating request is blocked', () => { + assert.equal(isDisallowedCrossOrigin('POST', H('null', 'localhost:8842')), true); +}); + +test('malformed Origin on a mutating request is blocked', () => { + assert.equal(isDisallowedCrossOrigin('DELETE', H('http://[bad', 'localhost:8842')), true); +}); + +test('PUT and DELETE are gated like POST', () => { + assert.equal(isDisallowedCrossOrigin('PUT', H('https://evil.example', 'localhost:8842')), true); + assert.equal(isDisallowedCrossOrigin('DELETE', H('https://evil.example', 'localhost:8842')), true); +}); + +test('GET is never gated, even cross-origin', () => { + assert.equal(isDisallowedCrossOrigin('GET', H('https://evil.example', 'localhost:8842')), false); +}); + +test('IPv6 loopback host matches its own Origin', () => { + assert.equal(isDisallowedCrossOrigin('POST', H('http://[::1]:8842', '[::1]:8842')), false); +}); + +test('127.0.0.1 page fetching a localhost:port host is treated as cross-origin', () => { + // Different host token → blocked. The frontend always fetches its own origin, + // so this only fires for a genuinely foreign page. + assert.equal(isDisallowedCrossOrigin('POST', H('http://127.0.0.1:8842', 'localhost:8842')), true); +}); + +test('same-origin is allowed regardless of Host header casing', () => { + // RFC 7230: Host is case-insensitive. A proxy that forwards "LocalHost" must + // not trigger a false-positive 403 on a legitimate same-origin request. + assert.equal(isDisallowedCrossOrigin('POST', H('http://localhost:8842', 'LocalHost:8842')), false); +}); + +test('Origin present but Host header missing is blocked (fail closed)', () => { + assert.equal(isDisallowedCrossOrigin('POST', { origin: 'https://evil.example', host: undefined }), true); +}); + +test('Referer fallback: cross-site Referer (no Origin) on a mutating request is blocked', () => { + assert.equal(isDisallowedCrossOrigin('POST', { referer: 'https://evil.example/x', host: 'localhost:8842' }), true); +}); + +test('Referer fallback: same-origin Referer (no Origin) is allowed', () => { + assert.equal(isDisallowedCrossOrigin('POST', { referer: 'http://localhost:8842/', host: 'localhost:8842' }), false); +}); + +test('Origin takes precedence over Referer when both are present', () => { + // A matching Origin allows even if Referer looks foreign (Origin is the + // stronger signal); a mismatched Origin blocks even with a matching Referer. + assert.equal(isDisallowedCrossOrigin('POST', { origin: 'http://localhost:8842', referer: 'https://evil.example/', host: 'localhost:8842' }), false); + assert.equal(isDisallowedCrossOrigin('POST', { origin: 'https://evil.example', referer: 'http://localhost:8842/', host: 'localhost:8842' }), true); +}); + +test('checkSameOrigin reports a reason for logging (used by the WS upgrade)', () => { + assert.deepEqual(checkSameOrigin({ origin: 'https://evil.example', host: 'localhost:8842' }), + { crossOrigin: true, reason: 'origin mismatch' }); + assert.deepEqual(checkSameOrigin({ origin: 'http://[bad', host: 'localhost:8842' }), + { crossOrigin: true, reason: 'bad origin' }); + assert.deepEqual(checkSameOrigin({ referer: 'https://evil.example/', host: 'localhost:8842' }), + { crossOrigin: true, reason: 'referer mismatch' }); + assert.deepEqual(checkSameOrigin({ host: 'localhost:8842' }), + { crossOrigin: false, reason: null }); +});