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.
|
||||
- **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 `<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.
|
||||
|
||||
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
|
||||
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
|
||||
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
|
||||
parsing and rendering; they use the neutral station color rather than reporting
|
||||
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
@@ -131,7 +131,9 @@
|
||||
"server/providers/common/http.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
"src/data/tomtomTiles.js",
|
||||
"server/providers/common/same-site.js",
|
||||
"src/localRequestGate.mjs"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
|
||||
@@ -8,6 +8,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.
|
||||
@@ -35,6 +36,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 `<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:
|
||||
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
|
||||
* without touching upstream — the traffic layer then stays in simulation mode.
|
||||
@@ -161,6 +169,8 @@ export function tomtomProxy() {
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
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
|
||||
// error details, and never echo the key or the upstream URL.
|
||||
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 { localProviderPlugins } from '../../server/providers/local.js';
|
||||
|
||||
function install(plugin) {
|
||||
function install(plugin, hook = 'configureServer') {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
assert.equal(typeof plugin[hook], 'function', `${hook} is available`);
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
@@ -17,9 +18,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 +35,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;
|
||||
};
|
||||
}
|
||||
@@ -191,6 +199,64 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback
|
||||
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) => {
|
||||
isolate(t, { TOMTOM_API_KEY: 'fixture-key', TOMTOM_DAILY_TILE_BUDGET: '' });
|
||||
const bodies = [];
|
||||
|
||||
Reference in new issue
Block a user