mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
f3bb697ed4
commit
55f979c128
14 files changed
+357
-49
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 `/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.
|
||||
|
||||
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
|
||||
expected 404 as an unavailable capability (asturksever, #1264).
|
||||
- Make tracking arrival checks respect the aircraft update cadence while checking
|
||||
orientation on the first reveal, avoiding false failures at faster render
|
||||
rates (asturksever, #1264).
|
||||
orientation on the first reveal, avoiding false failures at faster or slower
|
||||
render rates (asturksever, #1264).
|
||||
@@ -2799,9 +2799,12 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> first later successful fetch. Smaller chunks reduce timeout risk; complete
|
||||
> camera batches still wait for their sequential requests, and unresolved
|
||||
> placement continues to use the existing prior until real heights arrive.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded
|
||||
> POSTs through the shared local-request gate, then limits requests to 300/min
|
||||
> per socket peer and 900/min globally before reading or validating bodies.
|
||||
> - **Overpass cache admission:** `/api/overpass` refuses cross-site POSTs
|
||||
> through the keyless same-site gate (reverse-proxy forwarding headers are
|
||||
> 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.
|
||||
> It then checks fresh memory, identical in-flight work, and fresh disk entries
|
||||
> 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.
|
||||
- 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.
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -1069,5 +1069,6 @@
|
||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||
"src/terrainHeightsBounds.test.mjs",
|
||||
"server/providers/overpass/requestAdmission.test.mjs",
|
||||
"server/providers/common/rateLimitKeys.test.mjs",
|
||||
"src/tooling/trackArrivalObservation.test.mjs"
|
||||
]
|
||||
@@ -2697,9 +2697,11 @@ async function main() {
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The fleet runs every 80 ms,
|
||||
// independently of rendered FPS. Observe the first completed settle and
|
||||
// reveal frames within 500 ms of forcing moveEnd, before the 1 s idle
|
||||
// rotation refresh can repair an incorrect first reveal.
|
||||
// independently of rendered FPS. Observe the first completed settle frame
|
||||
// within 500 ms of forcing moveEnd and the first reveal frame within
|
||||
// 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');
|
||||
const arrival = await evalPage(async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIPv6 } from 'node:net';
|
||||
import { 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) {
|
||||
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));
|
||||
}
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function forwardedProtocol(req) {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
|
||||
* those protect credentials and spend, which a keyless route has neither of,
|
||||
|
||||
@@ -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 { admitSameSite } from './common/same-site.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
import {
|
||||
OVERPASS_MAX_BODY_BYTES,
|
||||
OVERPASS_MAX_CONCURRENT,
|
||||
@@ -29,10 +34,16 @@ const _overpassInFlight = new Map();
|
||||
let _overpassConcurrent = 0;
|
||||
|
||||
// Bound body-reading and validation work, including malformed requests and
|
||||
// cache hits. Keep the tighter upstream budget below independent of this gate.
|
||||
const _overpassRequestLimiter = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: 300,
|
||||
// cache hits, before a byte is read. Non-loopback peers share one global
|
||||
// window so address rotation cannot mint admission without bound (IPv6 peers
|
||||
// are keyed by /64 for the same reason); loopback — the owner's own browser
|
||||
// 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,
|
||||
});
|
||||
|
||||
@@ -42,6 +53,20 @@ const _overpassRateLimiter = makeRateLimiter({
|
||||
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.
|
||||
*
|
||||
@@ -103,8 +128,13 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (admitSameSite(req, res)) return;
|
||||
if (!_overpassRequestLimiter(clientKey(req))) {
|
||||
// Keyless gate: refuses cross-site browser requests but, unlike the
|
||||
// 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, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
@@ -163,7 +193,7 @@ function overpassProxy({ routing = {} } = {}) {
|
||||
// requests; boundary-class queries keep their month-long TTL.
|
||||
readDisk: () =>
|
||||
readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)),
|
||||
allowUpstream: () => _overpassRateLimiter(clientKey(req)),
|
||||
allowUpstream: () => _overpassRateLimiter(peer),
|
||||
});
|
||||
if (preflight.source === 'RATE_LIMITED') {
|
||||
res.writeHead(429, {
|
||||
|
||||
@@ -122,12 +122,14 @@ function sanitizeOverpassBody(rawBody) {
|
||||
const boundedSets = new Set();
|
||||
for (let stmt of withoutTags.split(';')) {
|
||||
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
|
||||
// removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be
|
||||
// misread as a spatial bound. Bounds live in (...) / function calls / set
|
||||
// refs, never inside [...], so the probe loses nothing real.
|
||||
// Strip output-set assignments (NOT input bounds). With the tag filters
|
||||
// already gone, a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot
|
||||
// be misread as a spatial bound; bounds live in (...) / function calls /
|
||||
// set refs, never inside [...], so nothing real was lost.
|
||||
const outSets = [];
|
||||
const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => {
|
||||
outSets.push(name);
|
||||
@@ -166,8 +168,10 @@ function sanitizeOverpassBody(rawBody) {
|
||||
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(
|
||||
/\[timeout:\s*(\d+)\s*\]/gi,
|
||||
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
|
||||
(_, n) =>
|
||||
`[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 { spawnSync } from 'node:child_process';
|
||||
import { overpassProxy } from '../overpass.js';
|
||||
import { _overpassCache } from './cache.js';
|
||||
import { sanitizeOverpassBody } from './query.js';
|
||||
|
||||
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]'),
|
||||
);
|
||||
}
|
||||
// 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', () => {
|
||||
@@ -126,7 +136,13 @@ for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ 'sec-fetch-site': 'same-site' },
|
||||
{ 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 });
|
||||
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();
|
||||
for (const headers of [
|
||||
{},
|
||||
{ origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' },
|
||||
{ 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 });
|
||||
assert.equal(response.statusCode, 400);
|
||||
@@ -176,3 +203,117 @@ test('invalid requests spend admission quota before parsing, including on an unc
|
||||
now += 60001;
|
||||
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);
|
||||
});
|
||||
@@ -3,13 +3,16 @@
|
||||
*
|
||||
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
|
||||
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
|
||||
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
|
||||
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||
* spend), `/api/google/nearby-places` (Google spend), and
|
||||
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
|
||||
* ungated, so a hostile web page open in the same browser could reach them
|
||||
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
|
||||
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
|
||||
* Content-Type. The endpoints behind this gate — `/api/realtime/token` (mints
|
||||
* an OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
|
||||
* spend), `/api/google/nearby-places` (Google spend),
|
||||
* `/api/realtime/debug-log` (appends to a local JSONL) and the quota-bearing
|
||||
* routes SECURITY.md lists with them — were previously ungated, so a hostile
|
||||
* web page open in the same browser could reach them cross-site: a `fetch`
|
||||
* 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
|
||||
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* Observe the first completed moveEnd frame and the first subsequent reveal.
|
||||
* The deadline starts when moveEnd is forced, before its rotation baseline, so
|
||||
* waiting for either frame cannot let the one-second idle refresh mask a bug.
|
||||
* `read` snapshots visibility and rotation error against the current position.
|
||||
* The settle deadline starts when moveEnd is forced, before its rotation
|
||||
* baseline; the reveal deadline restarts at the settle frame, which is where
|
||||
* the contact is hidden. `read` snapshots visibility and rotation error
|
||||
* against the current position.
|
||||
*/
|
||||
export function observeArrivalRotation({
|
||||
scene,
|
||||
@@ -18,6 +25,7 @@ export function observeArrivalRotation({
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = now();
|
||||
let phaseStartedAt = startedAt;
|
||||
let moveEnd = null;
|
||||
let moveEndMs = null;
|
||||
let revealFrames = 0;
|
||||
@@ -40,11 +48,16 @@ export function observeArrivalRotation({
|
||||
moveEndMs,
|
||||
flipFrame: -1,
|
||||
});
|
||||
const armDeadline = () => {
|
||||
clearTimer(timer);
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
};
|
||||
remove = scene.postRender.addEventListener(() => {
|
||||
if (done) return;
|
||||
try {
|
||||
const elapsedMs = now() - startedAt;
|
||||
if (elapsedMs >= ARRIVAL_OBSERVATION_MS) {
|
||||
const at = now();
|
||||
const elapsedMs = at - startedAt;
|
||||
if (at - phaseStartedAt >= ARRIVAL_OBSERVATION_MS) {
|
||||
finish(expired());
|
||||
return;
|
||||
}
|
||||
@@ -52,6 +65,8 @@ export function observeArrivalRotation({
|
||||
if (moveEnd === null) {
|
||||
moveEnd = sample;
|
||||
moveEndMs = elapsedMs;
|
||||
phaseStartedAt = at;
|
||||
armDeadline();
|
||||
startReveal();
|
||||
} else {
|
||||
revealFrames++;
|
||||
@@ -76,7 +91,7 @@ export function observeArrivalRotation({
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS);
|
||||
armDeadline();
|
||||
try {
|
||||
startMoveEnd();
|
||||
scene.requestRender();
|
||||
@@ -93,7 +108,8 @@ export function arrivalRotationPassed(observation) {
|
||||
!!observation &&
|
||||
!observation.error &&
|
||||
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.dReveal * 180) / Math.PI < 10
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
f.frame(8, { shown: true, rotationError: 0 });
|
||||
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;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
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 () => {
|
||||
const f = fixture();
|
||||
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;
|
||||
assert.match(result.error, /reveal.*500 ms/);
|
||||
assert.equal(result.elapsedMs, 501);
|
||||
assert.equal(result.elapsedMs, 509);
|
||||
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();
|
||||
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 });
|
||||
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.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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user