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 || '')); } /** * 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); // 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 stripped.split(';')) { stmt = stmt.trim(); if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue; // Strip output-set assignments (NOT input bounds), then strip bracket tag // filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be // misread as a spatial bound. Bounds live in (...) / function calls / set // refs, never inside [...], so the probe loses nothing real. const outSets = []; const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; }); const probe = body.replace(/\[[^\]]*\]/g, ' '); // 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(/(? 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); } const clamped = data.replace( /\[timeout:\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 };