fix(overpass): keyless gate, loopback admission headroom and per-phase arrival budgets

Address gekh's review of 69a87fe2:

- P1: /api/overpass now uses admitSameSiteKeyless, like /api/terrain/heights,
  so an instance behind the README's reverse proxy keeps its Overpass-backed
  layers; cross-site shapes are still refused and forwarding headers no
  longer launder them. SECURITY.md, the gate header and CURRENT-STATE say so.
- P2: admission keys IPv6 peers by /64 and folds IPv4-mapped peers
  (clientNetworkKey), and loopback has its own 300/min window outside the
  900/min global, so a few LAN peers cannot lock the owner's browser out.
- P2 (track): the settle and reveal frames each get their own 500 ms budget,
  measured from moveEnd and from the settle frame respectively, so a ~300 ms
  SwiftShader frame no longer trips the reveal deadline while both stay
  inside the 1 s idle refresh.
- Smaller: drop the dead settings-block check and stale probe comment in
  query.js, clamp whitespace-tolerant [ timeout : N ], and add tests for the
  global window, the /64 keying, loopback headroom, proxied admission and
  cache hits spending admission but not upstream budget.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-10-21 16:17:37 +01:00
1 parent f3bb697ed4
commit 55f979c128
14 files changed
+357 -49

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 `/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. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. - **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. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all non-loopback peers before it reads a body; loopback has its own per-peer window, so LAN peers cannot lock the owner out.
- **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.
@@ -5,5 +5,5 @@
browser validation can settle; the Overpass gate recognizes only that probe's browser validation can settle; the Overpass gate recognizes only that probe's
expected 404 as an unavailable capability (asturksever, #1264). expected 404 as an unavailable capability (asturksever, #1264).
- Make tracking arrival checks respect the aircraft update cadence while checking - Make tracking arrival checks respect the aircraft update cadence while checking
orientation on the first reveal, avoiding false failures at faster render orientation on the first reveal, avoiding false failures at faster or slower
rates (asturksever, #1264). render rates (asturksever, #1264).
+7 -4
View File
@@ -2799,9 +2799,12 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> first later successful fetch. Smaller chunks reduce timeout risk; complete > first later successful fetch. Smaller chunks reduce timeout risk; complete
> camera batches still wait for their sequential requests, and unresolved > camera batches still wait for their sequential requests, and unresolved
> placement continues to use the existing prior until real heights arrive. > placement continues to use the existing prior until real heights arrive.
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded > - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
> POSTs through the shared local-request gate, then limits requests to 300/min > through the keyless same-site gate (reverse-proxy forwarding headers are
> per socket peer and 900/min globally before reading or validating bodies. > admitted, as for `/api/terrain/heights`), then limits requests to 300/min
> per peer — IPv6 peers keyed by /64 — and 900/min across all non-loopback
> peers before reading or validating bodies; loopback has its own 300/min
> window outside that global.
> Tag-filter brackets are scanned once; nested or unmatched brackets return 400. > Tag-filter brackets are scanned once; nested or unmatched brackets return 400.
> It then checks fresh memory, identical in-flight work, and fresh disk entries > It then checks fresh memory, identical in-flight work, and fresh disk entries
> before invoking its separate local 90/min upstream limiter. Cache and > before invoking its separate local 90/min upstream limiter. Cache and
@@ -5078,7 +5081,7 @@ pins and share links already supply complete boxes.
- Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker. - Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker.
- Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere. - Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere.
- Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks. - Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks.
- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. - Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the settle frame and then the first reveal frame, each within a 500 ms wall-clock budget that sits inside the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers.
### TR-3B conversion Easter egg (August 2026) ### TR-3B conversion Easter egg (August 2026)
+1
View File
@@ -1069,5 +1069,6 @@
"src/data/gbfsProxyRateLimit.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs",
"src/terrainHeightsBounds.test.mjs", "src/terrainHeightsBounds.test.mjs",
"server/providers/overpass/requestAdmission.test.mjs", "server/providers/overpass/requestAdmission.test.mjs",
"server/providers/common/rateLimitKeys.test.mjs",
"src/tooling/trackArrivalObservation.test.mjs" "src/tooling/trackArrivalObservation.test.mjs"
] ]
+5 -3
View File
@@ -2697,9 +2697,11 @@ async function main() {
// Staged deterministically: park the camera nadir over the synthetic // Staged deterministically: park the camera nadir over the synthetic
// cluster, let rotations settle, TAMPER a billboard's rotation, then // cluster, let rotations settle, TAMPER a billboard's rotation, then
// trigger each path with the camera idle. The fleet runs every 80 ms, // trigger each path with the camera idle. The fleet runs every 80 ms,
// independently of rendered FPS. Observe the first completed settle and // independently of rendered FPS. Observe the first completed settle frame
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle // within 500 ms of forcing moveEnd and the first reveal frame within
// rotation refresh can repair an incorrect first reveal. // 500 ms of that settle frame: each budget sits inside the 1 s idle
// rotation refresh that could otherwise repair an incorrect first reveal,
// and a ~300 ms headless SwiftShader frame fits inside each of them.
// ============================================================ // ============================================================
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass'); console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
const arrival = await evalPage(async () => { const arrival = await evalPage(async () => {
+44
View File
@@ -1,3 +1,4 @@
import { isIPv6 } from 'node:net';
import { makeRateLimiter } from '../../../src/sources/rateLimit.js'; import { makeRateLimiter } from '../../../src/sources/rateLimit.js';
export { makeRateLimiter } from '../../../src/sources/rateLimit.js'; export { makeRateLimiter } from '../../../src/sources/rateLimit.js';
@@ -60,3 +61,46 @@ export function makeCostRateLimiter(envValue, defaultPerMin) {
export function clientKey(req) { export function clientKey(req) {
return String(req.socket?.remoteAddress || 'local'); return String(req.socket?.remoteAddress || 'local');
} }
const IPV4_MAPPED_RE = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
/**
* Whether a socket peer address is this machine: IPv4 loopback in any of its
* forms (`127.0.0.0/8`, including the IPv4-mapped `::ffff:127.x` a dual-stack
* listener reports) or the IPv6 loopback. Non-address keys are not loopback.
* @param {string} address - A `clientKey` value.
* @returns {boolean}
*/
export function isLoopbackPeer(address) {
const raw = String(address || '');
const v4 = IPV4_MAPPED_RE.exec(raw)?.[1] ?? raw;
return v4.startsWith('127.') || raw === '::1';
}
/**
* Rate-limit key that survives address rotation, for limiters whose global
* backstop everyone shares. IPv6 privacy extensions hand one host a fresh
* address at will inside its /64, so keying by the full address lets a single
* host mint per-peer quota until the global window closes for everyone else.
* IPv6 peers are keyed by their /64 prefix instead, and an IPv4-mapped
* address folds to its IPv4 form so one host cannot hold two buckets. Any
* other value (IPv4, or the `local` fallback) passes through unchanged.
* @param {import('node:http').IncomingMessage} req
* @returns {string}
*/
export function clientNetworkKey(req) {
const address = clientKey(req);
const mapped = IPV4_MAPPED_RE.exec(address);
if (mapped) return mapped[1];
const bare = address.replace(/%.*$/, ''); // link-local zone id
if (!isIPv6(bare)) return address;
const [head, tail = ''] = bare.split('::');
const headParts = head ? head.split(':') : [];
const tailParts = tail ? tail.split(':') : [];
const missing = Math.max(0, 8 - headParts.length - tailParts.length);
const hextets = [...headParts, ...Array(missing).fill('0'), ...tailParts];
return `${hextets
.slice(0, 4)
.map((h) => h.toLowerCase().replace(/^0+(?=.)/, ''))
.join(':')}::/64`;
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { clientNetworkKey, isLoopbackPeer } from './rate-limit.js';
const req = (remoteAddress) => ({ socket: { remoteAddress } });
test('clientNetworkKey keys IPv6 peers by /64 and folds IPv4-mapped peers', () => {
assert.equal(clientNetworkKey(req('2001:db8:1:2::1')), '2001:db8:1:2::/64');
assert.equal(
clientNetworkKey(req('2001:0db8:0001:0002:abcd:ef01:2345:6789')),
'2001:db8:1:2::/64',
);
assert.equal(clientNetworkKey(req('2001:DB8::1')), '2001:db8:0:0::/64');
assert.equal(clientNetworkKey(req('fe80::1%en0')), 'fe80:0:0:0::/64');
assert.equal(clientNetworkKey(req('::1')), '0:0:0:0::/64');
assert.equal(clientNetworkKey(req('::ffff:10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req('10.0.0.5')), '10.0.0.5');
assert.equal(clientNetworkKey(req(undefined)), 'local');
assert.equal(clientNetworkKey(req('not-an-address')), 'not-an-address');
});
test('isLoopbackPeer recognizes every loopback form and nothing else', () => {
for (const address of ['127.0.0.1', '127.9.9.9', '::1', '::ffff:127.0.0.1']) {
assert.equal(isLoopbackPeer(address), true, address);
}
for (const address of [
'10.0.0.1',
'::ffff:10.0.0.1',
'2001:db8::1',
'::2',
'local',
'',
undefined,
]) {
assert.equal(isLoopbackPeer(address), false, String(address));
}
});
+1 -1
View File
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
/** /**
* Cross-site gate for keyless routes the map needs in order to render * Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as * (`/api/terrain/heights`, `/api/overpass`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign * `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers: * or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of, * those protect credentials and spend, which a keyless route has neither of,
+39 -9
View File
@@ -1,6 +1,11 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import {
makeRateLimiter,
clientKey,
clientNetworkKey,
isLoopbackPeer,
} from './common/rate-limit.js';
import { readRequestBodyCapped } from './common/request.js'; import { readRequestBodyCapped } from './common/request.js';
import { admitSameSite } from './common/same-site.js'; import { admitSameSiteKeyless } from './common/same-site.js';
import { import {
OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_BODY_BYTES,
OVERPASS_MAX_CONCURRENT, OVERPASS_MAX_CONCURRENT,
@@ -29,10 +34,16 @@ const _overpassInFlight = new Map();
let _overpassConcurrent = 0; let _overpassConcurrent = 0;
// Bound body-reading and validation work, including malformed requests and // Bound body-reading and validation work, including malformed requests and
// cache hits. Keep the tighter upstream budget below independent of this gate. // cache hits, before a byte is read. Non-loopback peers share one global
const _overpassRequestLimiter = makeRateLimiter({ // window so address rotation cannot mint admission without bound (IPv6 peers
windowMs: 60_000, // are keyed by /64 for the same reason); loopback — the owner's own browser
max: 300, // and the repo QA harnesses — has its own per-peer window outside that global,
// so a few busy LAN peers cannot lock the owner out of the map. Keep the
// tighter upstream budget below independent of this gate.
const OVERPASS_ADMISSION_WINDOW = { windowMs: 60_000, max: 300 };
const _overpassLoopbackAdmission = makeRateLimiter(OVERPASS_ADMISSION_WINDOW);
const _overpassPeerAdmission = makeRateLimiter({
...OVERPASS_ADMISSION_WINDOW,
globalMax: 900, globalMax: 900,
}); });
@@ -42,6 +53,20 @@ const _overpassRateLimiter = makeRateLimiter({
globalMax: 300, globalMax: 300,
}); });
/**
* Spend one admission slot for this request's peer; false when it is over its
* window (or, for a non-loopback peer, when the shared global window is full).
* @param {import('http').IncomingMessage} req
* @param {string} peer - `clientNetworkKey(req)`.
* @returns {boolean}
*/
function admitOverpassRequest(req, peer) {
const admit = isLoopbackPeer(clientKey(req))
? _overpassLoopbackAdmission
: _overpassPeerAdmission;
return admit(peer);
}
/** /**
* Write a completed Overpass payload to the HTTP response. * Write a completed Overpass payload to the HTTP response.
* *
@@ -103,8 +128,13 @@ function overpassProxy({ routing = {} } = {}) {
return; return;
} }
if (admitSameSite(req, res)) return; // Keyless gate: refuses cross-site browser requests but, unlike the
if (!_overpassRequestLimiter(clientKey(req))) { // cost-bearing routes, admits the forwarding headers a reverse proxy
// adds — the layers that depend on Overpass must keep working for an
// instance deployed the way the README documents.
if (admitSameSiteKeyless(req, res)) return;
const peer = clientNetworkKey(req);
if (!admitOverpassRequest(req, peer)) {
res.writeHead(429, { res.writeHead(429, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
@@ -163,7 +193,7 @@ function overpassProxy({ routing = {} } = {}) {
// requests; boundary-class queries keep their month-long TTL. // requests; boundary-class queries keep their month-long TTL.
readDisk: () => readDisk: () =>
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
allowUpstream: () => _overpassRateLimiter(clientKey(req)), allowUpstream: () => _overpassRateLimiter(peer),
}); });
if (preflight.source === 'RATE_LIMITED') { if (preflight.source === 'RATE_LIMITED') {
res.writeHead(429, { res.writeHead(429, {
+10 -6
View File
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
const boundedSets = new Set(); const boundedSets = new Set();
for (let stmt of withoutTags.split(';')) { for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim(); stmt = stmt.trim();
if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue; // The settings block (`[out:json][timeout:25]`) was blanked together with
// the tag filters, so it arrives here as an empty statement.
if (!stmt || /^out\b/.test(stmt)) continue;
// Strip output-set assignments (NOT input bounds). Bracket tag filters were // Strip output-set assignments (NOT input bounds). With the tag filters
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be // already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
// misread as a spatial bound. Bounds live in (...) / function calls / set // be misread as a spatial bound; bounds live in (...) / function calls /
// refs, never inside [...], so the probe loses nothing real. // set refs, never inside [...], so nothing real was lost.
const outSets = []; const outSets = [];
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
outSets.push(name); outSets.push(name);
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
if (bounded) for (const name of outSets) boundedSets.add(name); if (bounded) for (const name of outSets) boundedSets.add(name);
} }
// Tolerate whitespace around the directive so `[ timeout : N ]` is clamped
// rather than passed through as written.
const clamped = data.replace( const clamped = data.replace(
/\[timeout:\s*(\d+)\s*\]/gi, /\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
(_, n) => (_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`, `[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
); );
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { overpassProxy } from '../overpass.js'; import { overpassProxy } from '../overpass.js';
import { _overpassCache } from './cache.js';
import { sanitizeOverpassBody } from './query.js'; import { sanitizeOverpassBody } from './query.js';
const form = (query) => `data=${encodeURIComponent(query)}`; const form = (query) => `data=${encodeURIComponent(query)}`;
@@ -37,6 +38,15 @@ test('bounded queries preserve quoted brackets, comments, multiple tags and time
query.replace('[timeout:999]', '[timeout:30]'), query.replace('[timeout:999]', '[timeout:30]'),
); );
} }
// Whitespace inside the directive is clamped too, not passed through.
const spaced = sanitizeOverpassBody(
form('[out:json][ timeout : 999 ];node(around:20,1,1);out;'),
);
assert.equal(spaced.ok, true);
assert.equal(
new URLSearchParams(spaced.body).get('data'),
'[out:json][timeout:30];node(around:20,1,1);out;',
);
}); });
test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => { test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => {
@@ -126,7 +136,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
{ 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'cross-site' },
{ 'sec-fetch-site': 'same-site' }, { 'sec-fetch-site': 'same-site' },
{ origin: 'null' }, { origin: 'null' },
{ 'x-forwarded-for': '127.0.0.1' }, // Forwarding headers do not launder a cross-site request.
{ 'x-forwarded-for': '10.0.0.9', 'sec-fetch-site': 'cross-site' },
{
'x-forwarded-proto': 'https',
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
},
]) { ]) {
const response = await call(route, { headers, forbidRead: true }); const response = await call(route, { headers, forbidRead: true });
assert.equal(response.statusCode, 403); assert.equal(response.statusCode, 403);
@@ -136,12 +152,23 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
}); });
} }
test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => { test('same-origin, LAN, proxied and non-browser requests still reach validation; GET status stays available', async () => {
const route = handler(); const route = handler();
for (const headers of [ for (const headers of [
{}, {},
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
{ host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' }, { host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' },
// The README's reverse-proxy deployment: Overpass is keyless, so the
// forwarding headers the proxy adds are admitted (as for terrain heights).
{
host: 'globe.example.lan',
origin: 'https://globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'globe.example.lan',
},
{ 'x-forwarded-for': '127.0.0.1' },
]) { ]) {
const response = await call(route, { headers }); const response = await call(route, { headers });
assert.equal(response.statusCode, 400); assert.equal(response.statusCode, 400);
@@ -176,3 +203,117 @@ test('invalid requests spend admission quota before parsing, including on an unc
now += 60001; now += 60001;
assert.equal((await call(route, { peer })).statusCode, 400); assert.equal((await call(route, { peer })).statusCode, 400);
}); });
// Each limiter test mocks Date.now well past the previous test's window so the
// module-level limiters start from an empty window.
test('the 900/min global window spans non-loopback peers only; loopback keeps its own window', async (t) => {
let now = Date.now() + 3_600_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (const peer of ['10.0.0.1', '10.0.0.2', '10.0.0.3']) {
for (let i = 0; i < 300; i++) {
assert.equal((await call(route, { peer })).statusCode, 400, peer);
}
}
const starved = await call(route, { peer: '10.0.0.4', forbidRead: true });
assert.equal(starved.statusCode, 429, 'global window is full for LAN peers');
assert.equal(starved.reads, 0);
for (const peer of ['127.0.0.1', '::1', '::ffff:127.0.0.1']) {
const owner = await call(route, { peer });
assert.equal(owner.statusCode, 400, `${peer} is admitted past the global`);
assert.equal(owner.reads, 1);
}
now += 60_001;
assert.equal((await call(route, { peer: '10.0.0.4' })).statusCode, 400);
});
test('IPv6 peers share a /64 admission bucket and IPv4-mapped peers fold to IPv4', async (t) => {
let now = Date.now() + 7_200_000;
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', () =>
assert.fail('unexpected upstream fetch'),
);
const route = handler();
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: `2001:db8:1:2::${(i % 200) + 1}` }))
.statusCode,
400,
);
}
const rotated = await call(route, {
peer: '2001:db8:1:2:ffff:ffff:ffff:9',
forbidRead: true,
});
assert.equal(rotated.statusCode, 429, 'a rotating host stays in one bucket');
assert.equal((await call(route, { peer: '2001:db8:1:3::1' })).statusCode, 400);
for (let i = 0; i < 300; i++) {
assert.equal(
(await call(route, { peer: '::ffff:10.1.1.1' })).statusCode,
400,
);
}
const unmapped = await call(route, { peer: '10.1.1.1', forbidRead: true });
assert.equal(unmapped.statusCode, 429, 'mapped and bare IPv4 share a bucket');
now += 60_001;
assert.equal((await call(route, { peer: '10.1.1.1' })).statusCode, 400);
});
test('cache hits spend admission budget but not the 90/min upstream budget', async (t) => {
let now = Date.now() + 10_800_000;
t.mock.method(Date, 'now', () => now);
const prior = process.env.OVERPASS_UPSTREAMS;
process.env.OVERPASS_UPSTREAMS = 'https://overpass.example/api/interpreter';
t.after(() => {
if (prior === undefined) delete process.env.OVERPASS_UPSTREAMS;
else process.env.OVERPASS_UPSTREAMS = prior;
_overpassCache.delete(hitKey);
});
let fetches = 0;
t.mock.method(globalThis, 'fetch', async () => {
fetches++;
// Not data: nothing is cached or written to disk by this test.
return new Response('upstream unavailable', { status: 503 });
});
const hit = 'node(around:20,1,1);out;';
const hitKey = `data=${encodeURIComponent(hit)}`;
_overpassCache.set(hitKey, {
status: 200,
body: '{"elements":[]}',
contentType: 'application/json',
endpoint: 'https://overpass.example/api/interpreter',
cachedAt: now,
});
const route = handler();
const peer = '10.7.7.7';
// More cache hits than the upstream limiter would allow per peer.
for (let i = 0; i < 100; i++) {
const response = await call(route, { peer, body: form(hit) });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['x-overpass-cache'], 'HIT');
}
assert.equal(fetches, 0);
const miss = await call(route, {
peer,
body: form('node(around:20,2,2);out;'),
});
assert.equal(fetches, 1, 'the first miss still reaches upstream');
assert.notEqual(miss.statusCode, 429);
for (let i = 0; i < 199; i++) {
assert.equal(
(await call(route, { peer, body: form(hit) })).statusCode,
200,
);
}
const exhausted = await call(route, {
peer,
body: form(hit),
forbidRead: true,
});
assert.equal(exhausted.statusCode, 429, 'hits spent the admission window');
assert.equal(exhausted.reads, 0);
});
+10 -7
View File
@@ -3,13 +3,16 @@
* *
* The credential panel has its own stricter gate (`admitKeySetupRequest` in * The credential panel has its own stricter gate (`admitKeySetupRequest` in
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON * keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an * Content-Type. The endpoints behind this gate — `/api/realtime/token` (mints
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI * an OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
* spend), `/api/google/nearby-places` (Google spend), and * spend), `/api/google/nearby-places` (Google spend),
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously * `/api/realtime/debug-log` (appends to a local JSONL) and the quota-bearing
* ungated, so a hostile web page open in the same browser could reach them * routes SECURITY.md lists with them — were previously ungated, so a hostile
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation * web page open in the same browser could reach them cross-site: a `fetch`
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`. * carries a foreign `Origin`, and an `<img>`/navigation carries NO `Origin`
* at all but does carry `Sec-Fetch-Site: cross-site`. The keyless map routes
* (`/api/terrain/heights`, `/api/overpass`) reuse this core through
* `admitSameSiteKeyless` with the forwarding-header refusal switched off.
* *
* This module is the pure core that refuses those shapes while keeping the * This module is the pure core that refuses those shapes while keeping the
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two * documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
+24 -8
View File
@@ -1,11 +1,18 @@
/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */ /**
* Wall-clock budget for EACH observed frame, regardless of FPS. Both the settle
* frame (measured from the forced moveEnd) and the reveal frame (measured from
* the settle frame that hid the contact) must land inside it, so neither can be
* repaired by the one-second idle rotation refresh before it is read — while a
* ~300 ms software-rendered frame (headless SwiftShader) still fits twice.
*/
export const ARRIVAL_OBSERVATION_MS = 500; export const ARRIVAL_OBSERVATION_MS = 500;
/** /**
* Observe the first completed moveEnd frame and the first subsequent reveal. * Observe the first completed moveEnd frame and the first subsequent reveal.
* The deadline starts when moveEnd is forced, before its rotation baseline, so * The settle deadline starts when moveEnd is forced, before its rotation
* waiting for either frame cannot let the one-second idle refresh mask a bug. * baseline; the reveal deadline restarts at the settle frame, which is where
* `read` snapshots visibility and rotation error against the current position. * the contact is hidden. `read` snapshots visibility and rotation error
* against the current position.
*/ */
export function observeArrivalRotation({ export function observeArrivalRotation({
scene, scene,
@@ -18,6 +25,7 @@ export function observeArrivalRotation({
}) { }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const startedAt = now(); const startedAt = now();
let phaseStartedAt = startedAt;
let moveEnd = null; let moveEnd = null;
let moveEndMs = null; let moveEndMs = null;
let revealFrames = 0; let revealFrames = 0;
@@ -40,11 +48,16 @@ export function observeArrivalRotation({
moveEndMs, moveEndMs,
flipFrame: -1, flipFrame: -1,
}); });
const armDeadline = () => {
clearTimer(timer);
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
};
remove = scene.postRender.addEventListener(() => { remove = scene.postRender.addEventListener(() => {
if (done) return; if (done) return;
try { try {
const elapsedMs = now() - startedAt; const at = now();
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) { const elapsedMs = at - startedAt;
if (at - phaseStartedAt >= ARRIVAL_OBSERVATION_MS) {
finish(expired()); finish(expired());
return; return;
} }
@@ -52,6 +65,8 @@ export function observeArrivalRotation({
if (moveEnd === null) { if (moveEnd === null) {
moveEnd = sample; moveEnd = sample;
moveEndMs = elapsedMs; moveEndMs = elapsedMs;
phaseStartedAt = at;
armDeadline();
startReveal(); startReveal();
} else { } else {
revealFrames++; revealFrames++;
@@ -76,7 +91,7 @@ export function observeArrivalRotation({
reject(error); reject(error);
} }
}); });
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS); armDeadline();
try { try {
startMoveEnd(); startMoveEnd();
scene.requestRender(); scene.requestRender();
@@ -93,7 +108,8 @@ export function arrivalRotationPassed(observation) {
!!observation && !!observation &&
!observation.error && !observation.error &&
observation.shown === true && observation.shown === true &&
observation.elapsedMs < ARRIVAL_OBSERVATION_MS && observation.moveEndMs < ARRIVAL_OBSERVATION_MS &&
observation.revealMs < ARRIVAL_OBSERVATION_MS &&
(observation.dMoveEnd * 180) / Math.PI < 10 && (observation.dMoveEnd * 180) / Math.PI < 10 &&
(observation.dReveal * 180) / Math.PI < 10 (observation.dReveal * 180) / Math.PI < 10
); );
+33 -6
View File
@@ -110,7 +110,8 @@ test('no reveal fails at a deadline before the one-second idle catch-up', async
assert.equal(ARRIVAL_OBSERVATION_MS, 500); assert.equal(ARRIVAL_OBSERVATION_MS, 500);
f.frame(8, { shown: true, rotationError: 0 }); f.frame(8, { shown: true, rotationError: 0 });
f.frame(480); f.frame(480);
f.expire(500); // The reveal budget is measured from the 8 ms settle frame.
f.expire(508);
const result = await f.observation; const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/); assert.match(result.error, /reveal.*500 ms/);
assert.equal(arrivalRotationPassed(result), false); assert.equal(arrivalRotationPassed(result), false);
@@ -123,20 +124,46 @@ test('no reveal fails at a deadline before the one-second idle catch-up', async
test('a late visible frame fails even if the timeout callback is delayed', async () => { test('a late visible frame fails even if the timeout callback is delayed', async () => {
const f = fixture(); const f = fixture();
f.frame(8, { shown: true, rotationError: 0 }); f.frame(8, { shown: true, rotationError: 0 });
f.frame(501, { shown: true, rotationError: 0 }); f.frame(509, { shown: true, rotationError: 0 });
const result = await f.observation; const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/); assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.elapsedMs, 501); assert.equal(result.elapsedMs, 509);
assert.equal(arrivalRotationPassed(result), false); assert.equal(arrivalRotationPassed(result), false);
}); });
test('the deadline includes moveEnd setup rather than restarting when hidden', async () => { test('a slow software renderer passes when each frame stays inside its own budget', async () => {
// Headless SwiftShader frames run ~300 ms: settle lands at 300 ms and the
// reveal at 600 ms from moveEnd. Both stay inside the 1 s idle refresh.
const f = fixture(); const f = fixture();
f.frame(400, { shown: true, rotationError: 0 }); f.frame(300, { shown: true, rotationError: 0 });
f.expire(500); // the settle frame re-armed the deadline to 800 ms
f.frame(600, { shown: true, rotationError: 0 }); f.frame(600, { shown: true, rotationError: 0 });
const result = await f.observation; const result = await f.observation;
assert.equal(result.moveEndMs, 400); assert.equal(result.moveEndMs, 300);
assert.equal(result.revealMs, 300);
assert.equal(result.elapsedMs, 600); assert.equal(result.elapsedMs, 600);
assert.equal(result.flipFrame, 1);
assert.equal(arrivalRotationPassed(result), true);
assert.equal(f.resources.timers, 0);
});
test('a settle frame later than its budget fails as a moveEnd timeout', async () => {
const f = fixture();
f.frame(500, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /moveEnd.*500 ms/);
assert.equal(result.moveEndMs, null);
assert.equal(arrivalRotationPassed(result), false);
});
test('a reveal later than its own budget fails even after a fast settle frame', async () => {
const f = fixture();
f.frame(400, { shown: true, rotationError: 0 });
f.frame(901, { shown: true, rotationError: 0 });
const result = await f.observation;
assert.match(result.error, /reveal.*500 ms/);
assert.equal(result.moveEndMs, 400);
assert.equal(result.elapsedMs, 901);
assert.equal(arrivalRotationPassed(result), false); assert.equal(arrivalRotationPassed(result), false);
}); });