Files
gods-eye-view/server/providers/overpass/query.js
T
Said TurkseverandClaude Fable 5.1 55f979c128 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]>
2026-10-21 16:17:37 +01:00

182 lines
7.2 KiB
JavaScript

import { stripOverpassNoise } from '../../../src/sources/overpass.js';
import {
OVERPASS_MAX_AROUND_M,
OVERPASS_MAX_BBOX_DEG,
OVERPASS_AREA_ELEMENT_RE,
OVERPASS_SELECTOR_RE,
OVERPASS_BBOX_RE,
OVERPASS_MAX_QL_TIMEOUT,
} from './constants.js';
/**
* Whether a normalized Overpass query is BOUNDARY-class (admin `is_in` lookups
* and area→relation pivots) — the static, expensive geometry that earns the
* 30-day disk TTL. The enclosing-compound sweep and road fetches keep the
* default TTL. Exported for tests.
*/
function isOverpassBoundaryQuery(cacheKey) {
return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || ''));
}
// Input has already had quoted literals and comments removed. Consume each
// character once: a regex that searches for a closing bracket from every `[` is
// quadratic on an unterminated filter. Nested or unmatched brackets are invalid.
function stripTagFilters(text) {
const parts = [];
let start = 0;
let inFilter = false;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '[') {
if (inFilter) return null;
parts.push(text.slice(start, i), ' ');
inFilter = true;
} else if (text[i] === ']') {
if (!inFilter) return null;
inFilter = false;
start = i + 1;
}
}
if (inFilter) return null;
parts.push(text.slice(start));
return parts.join('');
}
/**
* Validate + clamp an Overpass form body. Defends the generic proxy against
* planet-scale abuse: requires exactly one `data` query in which EVERY element
* selector is individually spatially bounded (around / bbox / is_in / poly /
* area-set / pivot), rejects oversized radii and world-sized bboxes, and clamps
* every `[timeout:]` directive. Comments + quoted literals are stripped first so
* a fake bound inside a tag value can't satisfy the check.
*
* Every real app caller passes (annotations/locations/cctv use `around:`/`is_in`/
* `area.`/`pivot`; traffic uses a small `(s,w,n,e)` bbox); a mixed query that
* pairs one bounded selector with a global one is rejected.
*
* @returns {{ok:true, body:string} | {ok:false, error:string}}
*/
function sanitizeOverpassBody(rawBody) {
let params;
try {
params = new URLSearchParams(rawBody);
} catch {
return { ok: false, error: 'Malformed query body' };
}
const all = params.getAll('data');
if (all.length !== 1)
return { ok: false, error: 'Exactly one data query is required' };
const data = all[0];
if (!data || !data.trim())
return { ok: false, error: 'Missing Overpass data query' };
// Blank quoted literals + strip comments in one lexer pass so a fake bound or a
// `//` inside a string can't hide an unbounded selector (or satisfy a bound).
const stripped = stripOverpassNoise(data);
const withoutTags = stripTagFilters(stripped);
if (withoutTags === null) {
return { ok: false, error: 'Malformed Overpass tag filter' };
}
// Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the
// input-set form `around.set:r` — and parse the full numeric token so scientific
// notation (`5e7`) can't slip a planet-scale radius past the cap.
for (const m of stripped.matchAll(/around(?:\.\w+)?:\s*([\d.eE+-]+)/gi)) {
const radius = Number(m[1]);
if (!Number.isFinite(radius) || radius > OVERPASS_MAX_AROUND_M) {
return { ok: false, error: 'Overpass around radius too large' };
}
}
// Reject world-sized / oversized bboxes.
for (const m of stripped.matchAll(
/\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g,
)) {
const s = Number(m[1]);
const w = Number(m[2]);
const n = Number(m[3]);
const e = Number(m[4]);
if (
Math.abs(n - s) > OVERPASS_MAX_BBOX_DEG ||
Math.abs(e - w) > OVERPASS_MAX_BBOX_DEG
) {
return { ok: false, error: 'Overpass bbox too large' };
}
}
// Reject control-flow constructs the app never uses — their set/bound semantics
// are hard to validate statically. The app only uses plain selectors + is_in /
// area / pivot / recursion, so this denylist closes loop/transform escape hatches.
if (/\b(?:foreach|complete|retro|compare|convert|make)\b/i.test(stripped)) {
return { ok: false, error: 'Unsupported Overpass construct' };
}
// `poly:` has unchecked extent and the app never uses it — reject outright
// (position-independent, so tag filters can't hide it).
if (/\bpoly\s*:/i.test(stripped)) {
return { ok: false, error: 'Overpass poly filter not allowed' };
}
// Every selector statement must be individually bounded, WITH set provenance: a
// set counts as a bound only if it was assigned (->.set) by an already-bounded
// statement. So `way[...]->.a` (global assigned to a set) is rejected, while the
// app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate.
const boundedSets = new Set();
for (let stmt of withoutTags.split(';')) {
stmt = stmt.trim();
// 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). 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);
return ' ';
});
// Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter
// between the selector and the area filter (way["highway"](area.a)) can't hide
// it. An area has unbounded extent (could be a whole country); the app only
// SELECTS admin areas (area.set) and pivots (rel(pivot.x)), never node/way/
// relation(area...). The probe collapses tags so `way (area.a)` is caught.
if (OVERPASS_AREA_ELEMENT_RE.test(probe)) {
return {
ok: false,
error: 'Overpass area-bounded element selector not allowed',
};
}
const hasSelector = OVERPASS_SELECTOR_RE.test(probe);
const inputSets = [...probe.matchAll(/(?<!\d)\.([a-z_]\w*)/gi)].map(
(m) => m[1],
);
const directBound =
/around:\s*\d/.test(probe) ||
OVERPASS_BBOX_RE.test(probe) ||
/is_in\s*\(/.test(probe) || // is_in(lat,lon) — the function form only
/\barea\s*\(/.test(probe); // area(id) — bounded as a set definition
const setBound = inputSets.some((s) => boundedSets.has(s));
const bounded = directBound || setBound;
if (hasSelector && !bounded) {
return { ok: false, error: 'Overpass query has an unbounded selector' };
}
// Only a bounded statement can mark its output sets as bounded.
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(
/\[\s*timeout\s*:\s*(\d+)\s*\]/gi,
(_, n) =>
`[timeout:${Math.min(Number(n) || OVERPASS_MAX_QL_TIMEOUT, OVERPASS_MAX_QL_TIMEOUT)}]`,
);
return { ok: true, body: `data=${encodeURIComponent(clamped)}` };
}
export { isOverpassBoundaryQuery, sanitizeOverpassBody };