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

Four dev-server endpoints were reachable cross-site from any page open in
the same browser: /api/realtime/token, /api/openai/hud-summary,
/api/google/nearby-places and /api/realtime/debug-log. A fetch carries a
foreign Origin; an <img> carries no Origin but Sec-Fetch-Site: cross-site.

Add a pure, unit-tested admitSameSiteRequest gate (proxy-signal headers,
Sec-Fetch-Site other than same-origin/none, foreign or opaque Origin all
refuse with 403) and call it in the four handlers. It does not require a
loopback socket, an Origin, or JSON, so the Node QA harnesses and the
HOST=0.0.0.0 LAN opt-in keep working. PROXY_SIGNALS is now one shared
export used by both gates.

Replace the frame-ancestors-only header with a full Content-Security-Policy
on dev and preview. script-src is 'self' 'unsafe-eval': Knockout inside
@cesium/widgets resolves the global object with eval at load time and the
Cesium widget does not initialize without it (verified in headless Chrome).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
James Sumpter 2026-09-09 10:43:47 -05:00
parent 759652207f
commit b31267c629
5 changed files with 301 additions and 3 deletions

View File

@ -28,7 +28,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys plus two non-secret CCTV feature flags. Everything else stays server-side.
> The Vite `define` block in `vite.config.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side.
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
@ -52,6 +52,8 @@ The data proxies in `vite.config.js` are written so the browser cannot turn the
- **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`).
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval'`: no inline script and no script from any other origin), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
## Network exposure — the operator threat model

View File

@ -101,6 +101,17 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/** Socket addresses that count as this machine. */
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
/**
* Reverse-proxy / CDN forwarding headers. Their presence means the request did
* not originate on this machine, whatever its socket says. Shared with the
* cost/log endpoint gate in localRequestGate.mjs so there is one definition.
*/
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',
]);
/** Parse an exact local request authority from a Host header. */
function localAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '').trim().toLowerCase();
@ -200,7 +211,6 @@ export function admitKeySetupRequest({
// on this machine, whatever its socket says. Refuse them outright as defense
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
// only appear when someone has deliberately fronted the dev server.
const PROXY_SIGNALS = ['forwarded', 'via', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'cf-ray'];
if (PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')) {
return { ok: false, status: 403, error: 'Provider Settings does not answer proxied requests' };
}

107
src/localRequestGate.mjs Normal file
View File

@ -0,0 +1,107 @@
/**
* 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.
*/
import { PROXY_SIGNALS } from './keySetupCore.mjs';
/**
* 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 {{method?: string, 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({
method,
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 };
}

View File

@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { admitSameSiteRequest } from './localRequestGate.mjs';
const same = {
method: 'POST',
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);
assert.equal(admitSameSiteRequest({ ...same, method: 'GET' }).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({
method: 'GET',
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({
method: 'POST',
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({
method: 'GET',
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({
method: 'GET',
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');
});

View File

@ -66,6 +66,7 @@ import {
upsertDotenvValues,
validateKeySetupUpdates,
} from './src/keySetupCore.mjs';
import { admitSameSiteRequest } from './src/localRequestGate.mjs';
import { hardenCredentialFile } from './src/keySetupHardening.mjs';
import {
fetchTerrainChunkWithRetry,
@ -5054,6 +5055,33 @@ function trackBackfillProxies() {
};
}
/**
* Cross-site request gate for the cost-bearing and log endpoints. Mirrors the
* credential-panel `admit` helper (below) but feeds the request to the pure,
* unit-tested `admitSameSiteRequest` in src/localRequestGate.mjs. Returns true
* when it has already responded 403 (caller returns); false when the request is
* admitted and the handler should continue. Refuses cross-site browser requests
* (foreign/opaque Origin, or Sec-Fetch-Site other than same-origin/none, or
* reverse-proxy headers) while keeping loopback non-browser tools and the LAN
* opt-in working.
*/
const admitSameSite = (req, res) => {
const verdict = admitSameSiteRequest({
method: req.method,
hostHeader: req.headers?.host,
protocol: req.socket?.encrypted ? 'https:' : 'http:',
origin: req.headers?.origin,
secFetchSite: req.headers?.['sec-fetch-site'],
proxyHeaders: req.headers || {},
});
if (verdict.ok) return false;
res.statusCode = verdict.status;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ error: verdict.error }));
return true;
};
/**
* Vite plugin: OpenAI Realtime ephemeral client secret.
*
@ -5069,6 +5097,7 @@ export function openAiRealtimeProxy() {
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
if (admitSameSite(req, res)) return;
const apiKey = process.env.OPENAI_API_KEY;
const keyless = keylessHudSummaryResponse(apiKey);
@ -5131,6 +5160,7 @@ export function openAiRealtimeProxy() {
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
if (admitSameSite(req, res)) return;
try {
const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES);
@ -5156,6 +5186,7 @@ export function openAiRealtimeProxy() {
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
if (admitSameSite(req, res)) return;
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
@ -5400,6 +5431,7 @@ export function googlePlacesContextProxy() {
res.end(JSON.stringify({ error: 'Method not allowed', places: [] }));
return;
}
if (admitSameSite(req, res)) return;
// Keyless place context has no provider cost, so it resolves before the
// paid-endpoint limiter can consume or exhaust quota (mirrors the HUD
@ -7721,6 +7753,32 @@ function keySetupEndpoint() {
};
}
/**
* Content-Security-Policy applied to every document the dev/preview server
* serves. Held in one constant so dev and preview cannot drift. No inline
* script and no foreign script origin is permitted. 'unsafe-eval' is required:
* Knockout (bundled inside @cesium/widgets) resolves the global object with
* `(0, eval)("this")` at module load, and without it the Cesium widget never
* initializes (verified in headless Chrome). It also covers Cesium's WASM
* decoders. Widen any other directive only if a real violation appears.
*/
const LOCAL_CSP = [
"default-src 'self'",
"script-src 'self' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com",
"img-src 'self' data: blob: https:",
"media-src 'self' blob: https:",
"connect-src 'self' https: wss: ws:",
"worker-src 'self' blob:",
"child-src 'self' blob:",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
/**
* Main Vite configuration factory.
*
@ -7781,7 +7839,15 @@ export default defineConfig(({ mode }) => {
// serves, which is what makes that attack impossible rather than unlikely.
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': "frame-ancestors 'none'",
'Content-Security-Policy': LOCAL_CSP,
},
},
// The preview server serves the same documents, so it carries the same
// framing + CSP hardening (one LOCAL_CSP constant, no drift).
preview: {
headers: {
'X-Frame-Options': 'DENY',
'Content-Security-Policy': LOCAL_CSP,
},
},
// Expose selected API keys to the browser via import.meta.env.*