Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/origin-guard.js
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 9 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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}`);
Expand Down
13 changes: 5 additions & 8 deletions src/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
}

Expand Down
89 changes: 89 additions & 0 deletions test/csrf-origin-guard.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading