fix(server): gate cost/log endpoints against cross-site requests and ship a full CSP

The cost-bearing endpoints (/api/realtime/token, /api/openai/hud-summary,
/api/google/nearby-places) and the voice debug log refuse cross-site browser
requests: a foreign or opaque Origin, a Sec-Fetch-Site other than
same-origin/none, or proxy forwarding headers. Loopback tools without an
Origin and the LAN opt-in keep working. The dev and preview servers send one
shared Content-Security-Policy, and embed-mode framing rewrites only the
frame-ancestors directive of it.

Salvaged from #242 onto current main.

Co-authored-by: Sameh Khamis <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
authored and Sameh Khamis committed 2026-10-02 19:56:42 -07:00
1 parent 42faed3aea
commit 7aa9bbd3be
11 files changed
+402 -15

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
/**
* Same-site request gate for the cost-bearing and log endpoints.
*
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
* spend), `/api/google/nearby-places` (Google spend), and
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
* ungated, so a hostile web page open in the same browser could reach them
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
*
* This module is the pure core that refuses those shapes while keeping the
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
* repo QA harnesses POST the token endpoint from Node with no Origin) working.
* It deliberately does NOT require a loopback remote address (that belongs only
* to the credential panel), the presence of an `Origin`, or a JSON
* Content-Type. It touches nothing but its arguments, so every refusal below is
* pinned by a unit assertion.
*/
/**
* Reverse-proxy / CDN forwarding headers. Their presence means the request did
* not originate on this machine, whatever its socket says. Kept as this
* module's own list (keySetupCore.mjs carries the same nine names for the
* credential panel) so the gate has no imports and can sit in any package
* boundary.
*/
export const PROXY_SIGNALS = Object.freeze([
'forwarded',
'via',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-port',
'x-forwarded-proto',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
]);
/**
* Compute a request's own authority (an origin string) from its protocol and
* Host header. Unlike the credential-panel gate's localhost-restricted
* `localAuthority`, this returns whatever Host the request actually carries —
* the LAN opt-in serves a non-loopback Host to LAN browsers, and a same-origin
* fetch from such a page must still match. Malformed/missing input yields null.
* @param {string} hostHeader e.g. `req.headers.host`
* @param {string} protocol `http:` or `https:`
* @returns {string|null}
*/
function requestAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '')
.trim()
.toLowerCase();
const scheme = String(protocol || '').toLowerCase();
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw))
return null;
try {
return new URL(`${scheme}//${raw}`).origin;
} catch {
return null;
}
}
/**
* Decide whether a request to a cost-bearing/log endpoint is same-site enough
* to admit. Pure: no I/O, no globals.
*
* Policy, in order:
* 1. any reverse-proxy / CDN signal header present (PROXY_SIGNALS) → 403;
* 2. `Sec-Fetch-Site` present and not `same-origin` / `none` → 403 (this is
* what blocks `<img src=...>` and cross-site navigation, which carry no
* Origin but do carry `Sec-Fetch-Site: cross-site`);
* 3. `Origin` present must exactly equal the request's own authority computed
* from protocol + Host — the same exact-origin comparison the credential
* gate uses (no userinfo, no path/search/hash). The literal string `null`
* is an opaque origin (sandboxed iframe / `data:` URL) and is refused;
* 4. otherwise ok. Non-browser loopback tools and the LAN opt-in (which may
* carry neither `Origin` nor `Sec-Fetch-Site`) pass here.
*
* @param {{hostHeader?: string, protocol?: string, origin?: string, secFetchSite?: string, proxyHeaders?: Record<string,string>}} req
* @returns {{ok: true} | {ok: false, status: 403, error: string}}
*/
export function admitSameSiteRequest({
hostHeader,
protocol = 'http:',
origin,
secFetchSite,
proxyHeaders = {},
} = {}) {
// (1) A request carrying reverse-proxy / CDN forwarding headers did not
// originate on this machine, whatever its socket says.
if (
PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')
) {
return {
ok: false,
status: 403,
error: 'Proxied requests are not accepted',
};
}
// (2) The browser tells us when a request is cross-site. `none` is a typed
// URL / bookmark; `same-origin` is the app itself. Anything else (cross-site,
// same-site but cross-origin) is refused.
const site = String(secFetchSite || '')
.trim()
.toLowerCase();
if (site !== '' && site !== 'same-origin' && site !== 'none') {
return {
ok: false,
status: 403,
error: 'Cross-site requests are not accepted',
};
}
// (3) If Origin is present it must exactly equal the request's own authority.
if (origin !== undefined && origin !== null && origin !== '') {
if (origin === 'null') {
return {
ok: false,
status: 403,
error: 'Opaque origins are not accepted',
};
}
const authority = requestAuthority(hostHeader, protocol);
let parsedOrigin;
try {
parsedOrigin = new URL(String(origin));
} catch {
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
}
const exactOrigin =
parsedOrigin.username === '' &&
parsedOrigin.password === '' &&
parsedOrigin.pathname === '/' &&
parsedOrigin.search === '' &&
parsedOrigin.hash === '' &&
parsedOrigin.origin === authority;
if (!exactOrigin) {
return {
ok: false,
status: 403,
error: 'Cross-origin requests are not accepted',
};
}
}
return { ok: true };
}
+107
View File
@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { admitSameSiteRequest } from './localRequestGate.mjs';
const same = {
hostHeader: 'localhost:4173',
protocol: 'http:',
origin: 'http://localhost:4173',
secFetchSite: 'same-origin',
};
test('the honest same-origin browser request is admitted', () => {
assert.equal(admitSameSiteRequest(same).ok, true);
});
test('a cross-site Origin is refused', () => {
const r = admitSameSiteRequest({ ...same, origin: 'https://evil.example' });
assert.equal(r.ok, false);
assert.equal(r.status, 403);
});
test('an opaque Origin ("null") is refused even when Host would match', () => {
const r = admitSameSiteRequest({ ...same, origin: 'null' });
assert.equal(r.ok, false);
assert.equal(r.status, 403);
});
test('Sec-Fetch-Site cross-site without an Origin is refused (the <img> case)', () => {
const r = admitSameSiteRequest({
hostHeader: 'localhost:4173',
protocol: 'http:',
secFetchSite: 'cross-site',
});
assert.equal(r.ok, false);
assert.equal(r.status, 403);
});
test('Sec-Fetch-Site same-site (cross-origin sibling) is refused', () => {
const r = admitSameSiteRequest({
...same,
origin: 'http://localhost:4173',
secFetchSite: 'same-site',
});
assert.equal(r.ok, false, 'same-site is not same-origin');
assert.equal(r.status, 403);
});
test('a reverse-proxy signal header is refused', () => {
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
const r = admitSameSiteRequest({ ...same, proxyHeaders: { [header]: 'anything' } });
assert.equal(r.ok, false, `${header} present → refused`);
}
// An empty forwarding header is not a proxy signal.
assert.equal(admitSameSiteRequest({ ...same, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
});
test('same-origin Origin with Sec-Fetch-Site same-origin is admitted', () => {
assert.equal(admitSameSiteRequest(same).ok, true);
});
test('no Origin and no Sec-Fetch headers (curl / Node harness) is admitted', () => {
const r = admitSameSiteRequest({
hostHeader: 'localhost:4173',
protocol: 'http:',
});
assert.equal(r.ok, true, 'non-browser loopback tools pass');
});
test('Sec-Fetch-Site none (typed URL / bookmark) is admitted', () => {
const r = admitSameSiteRequest({
hostHeader: 'localhost:4173',
protocol: 'http:',
secFetchSite: 'none',
});
assert.equal(r.ok, true);
});
test('a LAN remote address is irrelevant: the function takes no remoteAddress', () => {
// The credential-panel gate requires a loopback socket; this gate does not.
// Passing a LAN-shaped Host/Origin pair must still match (LAN opt-in works).
const lan = admitSameSiteRequest({
hostHeader: '192.168.1.5:4173',
protocol: 'http:',
origin: 'http://192.168.1.5:4173',
secFetchSite: 'same-origin',
});
assert.equal(lan.ok, true, 'a same-origin LAN browser request is admitted');
});
test('a cross-port or cross-scheme Origin against the same Host is refused', () => {
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://localhost:4174' }).ok, false, 'cross-port');
assert.equal(admitSameSiteRequest({ ...same, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme');
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host');
});
test('an unparseable Origin is refused', () => {
assert.equal(admitSameSiteRequest({ ...same, origin: 'not a url' }).ok, false);
});
test('a missing Host with a present Origin is refused (no authority to match)', () => {
assert.equal(admitSameSiteRequest({ ...same, hostHeader: '' }).ok, false, 'empty Host → null authority');
assert.equal(admitSameSiteRequest({ ...same, hostHeader: undefined }).ok, false, 'absent Host → null authority');
});
test('a foreign Host is refused when an Origin is present', () => {
assert.equal(admitSameSiteRequest({ ...same, hostHeader: 'evil.example:4173' }).ok, false, 'foreign Host does not match local Origin');
});
+28 -4
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createBrowserViteConfig } from '../../build/vite.js';
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
import standaloneConfig, * as compatibility from '../../vite.config.js';
import * as providers from '../../server/providers/local.js';
@@ -23,10 +23,18 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
assert.ok(config.server.fs.deny.includes('.env.*'));
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
assert.equal(
config.server.headers['Content-Security-Policy'],
const csp = config.server.headers['Content-Security-Policy'];
assert.equal(csp, BROWSER_CSP);
for (const directive of [
"script-src 'self' 'unsafe-eval' blob:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
);
]) {
assert.ok(csp.includes(directive), directive);
}
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
assert.deepEqual(config.preview.headers, config.server.headers);
assert.deepEqual(config.define, {
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
@@ -127,6 +135,22 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
anywhere.setHeader('X-Frame-Options', 'DENY');
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(anywhere.headers), {});
// A full policy keeps its other directives; only the framing rule changes.
const full = "default-src 'self'; script-src 'self'; frame-ancestors 'none'";
const embeddedFull = response();
middleware({ url: '/?embed=1' }, embeddedFull, () => {});
embeddedFull.setHeader('Content-Security-Policy', full);
assert.equal(
embeddedFull.headers.get('content-security-policy'),
"default-src 'self'; script-src 'self'; frame-ancestors https://a.example",
);
const anywhereFull = response();
open({ url: '/?embed=1' }, anywhereFull, () => {});
anywhereFull.setHeader('Content-Security-Policy', full);
assert.equal(
anywhereFull.headers.get('content-security-policy'),
"default-src 'self'; script-src 'self'",
);
const normal = response();
middleware({ url: '/' }, normal, () => {});
normal.setHeader('X-Frame-Options', 'DENY');