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);