From 6cdd6c8af9e2e4db67a638777e2fffe9195e82e8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 20:02:48 -0700 Subject: [PATCH] refactor(server): share one proxy-header check across the local gates localRequestGate.mjs owns the reverse-proxy header list and hasProxySignals; Provider Settings, the cross-site gate and /mcp all use it instead of two copies of the same nine names. The changelog credits the cross-site gate. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 +++++++ scripts/package-boundaries.json | 8 +++++++- server/mcp/plugin.js | 3 ++- src/keySetupCore.mjs | 29 ++++------------------------- src/localRequestGate.mjs | 21 ++++++++++++++------- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ae6916e..05ba5497e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +- Refuse cross-site browser requests to the cost-bearing and log endpoints + (`/api/realtime/token`, `/api/openai/hud-summary`, + `/api/google/nearby-places`, `/api/realtime/debug-log`): a foreign or opaque + Origin, a cross-site `Sec-Fetch-Site`, or proxy forwarding headers get 403, + while loopback tools and LAN use keep working. The dev and preview servers + send one shared Content-Security-Policy (James Sumpter, #242). + - The voice debug log records the server's own time: a record can no longer supply its `loggedAt`. The dev and preview servers also send `X-Content-Type-Options: nosniff` (findings by Sunil, #710). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 1c86aff2b..ab28b67e0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -306,6 +306,7 @@ "server/providers/common/source-root.js", "server/standalone/key-setup.js", "src/keySetupCore.mjs", + "src/localRequestGate.mjs", "server/standalone/key-setup-hardening.mjs" ], "external": [] @@ -390,7 +391,8 @@ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/mapStackChips.js", - "src/keySetupCore.mjs" + "src/keySetupCore.mjs", + "src/localRequestGate.mjs" ], "external": [] }, @@ -409,6 +411,7 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js", "src/keySetupCore.mjs", + "src/localRequestGate.mjs", "src/data/feedState.js", "src/data/layerSnapshot.js" ], @@ -529,6 +532,7 @@ "src/data/militaryAwarenessEngine.js", "src/data/scenePick.js", "src/keySetupCore.mjs", + "src/localRequestGate.mjs", "src/loadingFeedback.js", "src/locationStatus.js", "src/mapStackChips.js", @@ -1266,6 +1270,7 @@ "src/maps/terrain.js", "src/maps/terrainRetry.js", "src/keySetupCore.mjs", + "src/localRequestGate.mjs", "src/maps/imageryHost.js" ], "external": ["cesium"] @@ -1511,6 +1516,7 @@ "src/hudSummaryResponse.js", "src/keySetup.js", "src/keySetupCore.mjs", + "src/localRequestGate.mjs", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", "src/layers/alpr/index.js", diff --git a/server/mcp/plugin.js b/server/mcp/plugin.js index 75ad2b558..f83cc4f7b 100644 --- a/server/mcp/plugin.js +++ b/server/mcp/plugin.js @@ -7,7 +7,8 @@ * rebinding) and, when a browser sends an Origin, come from that same host. */ -import { hasProxySignals, isSharingEnabled } from '../../src/keySetupCore.mjs'; +import { isSharingEnabled } from '../../src/keySetupCore.mjs'; +import { hasProxySignals } from '../../src/localRequestGate.mjs'; import { createMcpHttpHandler } from '../../src/tools/mcp/index.js'; import { createLocalMcpServer } from './server.js'; diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index a98aadc4e..75c1fa019 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -1,7 +1,8 @@ /** * Key setup ("POWER UP") — the pure core. * - * One registry, three pure functions, zero dependencies. The dev server's + * One registry and pure functions; its only import is the proxy-header check + * shared with the other local gates (localRequestGate.mjs). The dev server's * /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js) * are both thin shells over this module, so what a key is called, what it * unlocks, and how a .env line is written each live in exactly one place. @@ -11,6 +12,8 @@ * behavior below unit-testable. */ +import { hasProxySignals } from './localRequestGate.mjs'; + /** Longest accepted key/token value. Real provider keys are all far shorter. */ export const KEY_SETUP_VALUE_LIMIT = 512; @@ -184,30 +187,6 @@ export function parseWindowsUserSid(stdout) { : null; } -/** Headers that reverse proxies, tunnels and CDNs add to a request. */ -export const PROXY_SIGNAL_HEADERS = Object.freeze([ - 'forwarded', - 'via', - 'x-forwarded-for', - 'x-forwarded-host', - 'x-forwarded-port', - 'x-forwarded-proto', - 'x-real-ip', - 'cf-connecting-ip', - 'cf-ray', -]); - -/** - * Whether a request carries reverse-proxy or CDN forwarding headers, so it - * did not originate on this machine whatever its socket says. `headers` is - * keyed by lower-case header name. - */ -export function hasProxySignals(headers = {}) { - return PROXY_SIGNAL_HEADERS.some( - (name) => String(headers[name] || '').trim() !== '', - ); -} - /** * Whether the launcher has sharing on. Every sharing signal the launcher * recognizes (scripts/pinokio-preflight.mjs) counts, so the two sets cannot diff --git a/src/localRequestGate.mjs b/src/localRequestGate.mjs index 0dfc0fe73..066138984 100644 --- a/src/localRequestGate.mjs +++ b/src/localRequestGate.mjs @@ -21,10 +21,9 @@ */ /** * 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. + * not originate on this machine, whatever its socket says. This module owns + * the list for every local gate (the credential panel and /mcp import + * `hasProxySignals`), and has no imports so it can sit in any package boundary. */ export const PROXY_SIGNALS = Object.freeze([ 'forwarded', @@ -38,6 +37,16 @@ export const PROXY_SIGNALS = Object.freeze([ 'cf-ray', ]); +/** + * Whether a request carries reverse-proxy or CDN forwarding headers. `headers` + * is keyed by lower-case header name. + */ +export function hasProxySignals(headers = {}) { + return PROXY_SIGNALS.some( + (name) => String(headers[name] || '').trim() !== '', + ); +} + /** * Compute a request's own authority (an origin string) from its protocol and * Host header. Unlike the credential-panel gate's localhost-restricted @@ -90,9 +99,7 @@ export function admitSameSiteRequest({ } = {}) { // (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() !== '') - ) { + if (hasProxySignals(proxyHeaders)) { return { ok: false, status: 403,