Merge pull request #973 from Sunil56224972/fix/tomtom-cross-site-gate

fix(traffic): refuse cross-site requests before they spend the TomTom budget
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-16 17:01:31 -07:00
commit 79176667b0
6 files changed
+92 -7

No files matched your search

+1 -1
View File
@@ -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. - **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **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. - **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 `<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`). 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 `<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`). 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 and the Maps JavaScript API that Street View runs on, 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 `<style>` elements. - **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 and the Maps JavaScript API that Street View runs on, 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 `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
@@ -0,0 +1,4 @@
- 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 (Sunil56224972, #973).
+4 -1
View File
@@ -1392,7 +1392,10 @@ plugin order, server-key selection, validation, disk caches, budgets, retries
and stale/error responses remain unchanged. Each has a Node-only package entry and stale/error responses remain unchanged. Each has a Node-only package entry
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
math and GBFS source rules are available under `gods-eye-view/sources/`. math and GBFS source rules are available under `gods-eye-view/sources/`.
The browser layers and their rendering remain in their existing modules. The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
status, cache or upstream work, refusing cross-site browser requests while
preserving same-origin app requests and non-browser local callers. The browser
layers and their rendering remain in their existing modules.
GBFS null or empty availability counts retain an unknown value through station GBFS null or empty availability counts retain an unknown value through station
parsing and rendering; they use the neutral station color rather than reporting parsing and rendering; they use the neutral station color rather than reporting
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
+3 -1
View File
@@ -131,7 +131,9 @@
"server/providers/common/http.js", "server/providers/common/http.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/data/tomtomTiles.js" "src/data/tomtomTiles.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
], ],
"external": [] "external": []
}, },
+10
View File
@@ -8,6 +8,7 @@ import {
normalizeBudget as normalizeTomTomBudget, normalizeBudget as normalizeTomTomBudget,
isOverBudget as isTomTomOverBudget, isOverBudget as isTomTomOverBudget,
} from '../../src/data/tomtomTiles.js'; } from '../../src/data/tomtomTiles.js';
import { admitSameSite } from './common/same-site.js';
/** /**
* TomTom traffic-flow vector-tile proxy with a daily budget governor. * TomTom traffic-flow vector-tile proxy with a daily budget governor.
@@ -35,6 +36,13 @@ import {
* for the rest of the billing period. 6,000/day keeps a full 31-day month * 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. * 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 `<img>` 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: * GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'} * status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
* without touching upstream — the traffic layer then stays in simulation mode. * without touching upstream — the traffic layer then stays in simulation mode.
@@ -161,6 +169,8 @@ export function tomtomProxy() {
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/tomtom', async (req, res) => { server.middlewares.use('/api/tomtom', async (req, res) => {
// Gate first: a cross-site <img>/fetch must not reach the budget counter.
if (admitSameSite(req, res)) return;
// Sanitized responses only (proxy/security baseline): no upstream // Sanitized responses only (proxy/security baseline): no upstream
// error details, and never echo the key or the upstream URL. // error details, and never echo the key or the upstream URL.
const sendJson = (status, obj, extraHeaders = {}) => { const sendJson = (status, obj, extraHeaders = {}) => {
+70 -4
View File
@@ -7,9 +7,10 @@ import { firmsProxy } from 'gods-eye-view/server/providers/firms';
import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs'; import { gbfsProxy } from 'gods-eye-view/server/providers/gbfs';
import { localProviderPlugins } from '../../server/providers/local.js'; import { localProviderPlugins } from '../../server/providers/local.js';
function install(plugin) { function install(plugin, hook = 'configureServer') {
const routes = new Map(); const routes = new Map();
plugin.configureServer({ assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
plugin[hook]({
middlewares: { middlewares: {
use(route, handler) { use(route, handler) {
routes.set(route, handler); routes.set(route, handler);
@@ -17,9 +18,16 @@ function install(plugin) {
}, },
}); });
assert.equal(routes.size, 1); assert.equal(routes.size, 1);
return async (url = '/', method = 'GET') => { return async (url = '/', method = 'GET', headers = {}) => {
const res = { const res = {
headersSent: false, headersSent: false,
headers: {},
set statusCode(status) {
this.status = status;
},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status, headers) { writeHead(status, headers) {
Object.assign(this, { status, headers, headersSent: true }); Object.assign(this, { status, headers, headersSent: true });
}, },
@@ -27,7 +35,7 @@ function install(plugin) {
this.body = body; this.body = body;
}, },
}; };
await [...routes.values()][0]({ url, method }, res); await [...routes.values()][0]({ url, method, headers, socket: {} }, res);
return res; return res;
}; };
} }
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
assert.equal(calls, 2); assert.equal(calls, 2);
}); });
test('traffic refuses cross-site requests in dev and preview before spending 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 host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> 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
];
for (const [mode, hook] of [
['dev', 'configureServer'],
['preview', 'configurePreviewServer'],
]) {
const request = install(tomtomProxy(), hook);
// Each refused shape asks for distinct uncached tiles: without the gate
// every one would be a billed 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, `${mode}: ${JSON.stringify(headers)}`);
}
assert.equal(
(await request('/status', 'GET', refused[0])).status,
403,
`${mode}: status is gated too`,
);
assert.equal(
calls,
mode === 'dev' ? 0 : 2,
`${mode}: no refused upstream work`,
);
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, mode === 'dev' ? 2 : 4);
}
});
test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => { test('traffic middleware answers 502 to an oversized tile without caching it', async (t) => {
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' }); isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
const bodies = []; const bodies = [];