mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
182 lines
7.2 KiB
JavaScript
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 };
|