mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
6 files changed
+92
-7
No files matched your search
+1
-1
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 = {}) => {
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
Reference in new issue
Block a user