From 1f1dc2dca8a99ad48402517f4a2906936d6822cb Mon Sep 17 00:00:00 2001 From: Sunil Date: Thu, 8 Oct 2026 20:59:38 +0530 Subject: [PATCH 1/7] fix(traffic): refuse cross-site requests before they spend the TomTom budget /api/tomtom/flow was the only keyed, billed proxy left outside the cross-site gate added in #900. Every uncached tile is an upstream attempt counted against the persistent daily budget, so any web page the user visits could fire requests at distinct z8-z16 tiles, spend the TomTom quota and leave the traffic layer on 429 budget for the rest of the UTC day. Gate both routes with admitSameSite, like the Places routes. --- scripts/package-boundaries.json | 7 ++- server/providers/traffic.js | 10 ++++ src/tooling/environmentProviders.test.mjs | 56 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2a188b4b0..f253f924a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -123,7 +123,12 @@ "traffic-provider": { "runtime": "node", "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" + ], "external": [] }, "traffic-source": { diff --git a/server/providers/traffic.js b/server/providers/traffic.js index 2a3c2e60a..b078ff2df 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -7,6 +7,7 @@ import { normalizeBudget as normalizeTomTomBudget, isOverBudget as isTomTomOverBudget, } from '../../src/data/tomtomTiles.js'; +import { admitSameSite } from './common/same-site.js'; /** * TomTom traffic-flow vector-tile proxy with a daily budget governor. @@ -34,6 +35,13 @@ import { * for the rest of the billing period. 6,000/day keeps a full 31-day month * inside the allowance (186,000) with headroom to spare. * + * Cross-site gate: both routes refuse cross-site browser requests with 403 + * (the gate Mapillary, OpenAI and Places already use). Every uncached tile is + * a billed upstream attempt counted against the persistent daily budget, so + * without the gate any web page the user visits could fire `` requests + * at distinct tiles, spend the TomTom quota and leave the traffic layer on + * 429 budget for the rest of the UTC day. + * * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode: * status reports hasKey:false and the tile endpoint 503s {error:'no_key'} * without touching upstream — the traffic layer then stays in simulation mode. @@ -154,6 +162,8 @@ export function tomtomProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/tomtom', async (req, res) => { + // Gate first: a cross-site /fetch must not reach the budget counter. + if (admitSameSite(req, res)) return; // Sanitized responses only (proxy/security baseline): no upstream // error details, and never echo the key or the upstream URL. const sendJson = (status, obj, extraHeaders = {}) => { diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index f8f02595c..ffdff0600 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -17,9 +17,16 @@ function install(plugin) { }, }); assert.equal(routes.size, 1); - return async (url = '/', method = 'GET') => { + return async (url = '/', method = 'GET', headers = {}) => { const res = { headersSent: false, + headers: {}, + set statusCode(status) { + this.status = status; + }, + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, @@ -27,7 +34,7 @@ function install(plugin) { this.body = body; }, }; - await [...routes.values()][0]({ url, method }, res); + await [...routes.values()][0]({ url, method, headers, socket: {} }, res); return res; }; } @@ -168,6 +175,51 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback assert.equal(calls, 2); }); +test('traffic refuses cross-site requests before they spend the TomTom budget', async (t) => { + isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '3' }); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response(new Uint8Array([1, 2, 3])); + }); + const request = install(tomtomProxy()); + const host = 'localhost:5173'; + const refused = [ + { host, 'sec-fetch-site': 'cross-site' }, // from any web page + { host, 'sec-fetch-site': 'same-site' }, + { host, origin: 'https://evil.example' }, // fetch from another page + { host, origin: 'null' }, // sandboxed frame + { host, 'x-forwarded-for': '203.0.113.9' }, // through a proxy + ]; + // Each refused shape asks for distinct uncached tiles: on main every one + // was a billed upstream attempt counted against the daily budget. + let x = 0; + for (const headers of refused) + for (let i = 0; i < 2; i++) { + const res = await request(`/flow/16/${x++}/0.pbf`, 'GET', headers); + assert.equal(res.status, 403, JSON.stringify(headers)); + } + assert.equal((await request('/status', 'GET', refused[0])).status, 403); + assert.equal(calls, 0, 'TomTom is never asked'); + const app = { host, 'sec-fetch-site': 'same-origin' }; + assert.equal(json(await request('/status', 'GET', app)).dailyCount, 0); + // The app itself and loopback tools (no browser headers) still get tiles. + const tile = await request('/flow/8/1/1.pbf', 'GET', app); + assert.equal(tile.status, 200); + assert.equal(tile.headers['x-tomtom-cache'], 'MISS'); + assert.equal((await request('/flow/8/2/1.pbf')).status, 200); + assert.equal( + ( + await request('/flow/8/1/1.pbf', 'GET', { + host, + origin: `http://${host}`, + }) + ).headers['x-tomtom-cache'], + 'HIT', + ); + assert.equal(calls, 2); +}); + test('FIRMS retains a large successful source during partial failure and filters stale data at serve time', async (t) => { isolate(t, { FIRMS_MAP_KEY: '' }); let now = Date.UTC(2026, 8, 12, 12); From ce5df4b8abe1b254b9a2fc73f1308e90f776768b Mon Sep 17 00:00:00 2001 From: Sunil Date: Fri, 9 Oct 2026 14:44:29 +0530 Subject: [PATCH 2/7] docs(security): include TomTom in same-site gate inventory --- CHANGELOG.md | 5 +++++ SECURITY.md | 2 +- docs/CURRENT-STATE.md | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f343b4d2..3dfacb35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,11 @@ ## [Unreleased] +- Cross-site browser requests can no longer spend the TomTom traffic-tile + budget through `/api/tomtom/*`. The shared same-site gate runs before status, + cache, and upstream work; same-origin app requests and non-browser local + callers remain supported. + - Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider Settings. API-key voice remains the default. Local sign-in can start from the auth button and reports completion, failure, timeout, or expired diff --git a/SECURITY.md b/SECURITY.md index 53437a83c..fdb10b877 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **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`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) 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 ``/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`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. +- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) 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 ``/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`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), 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 `