mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
162 lines
7.3 KiB
JavaScript
162 lines
7.3 KiB
JavaScript
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}}
|
|
*/
|
|
/**
|
|
* Single-pass lexer: blank out quoted literals (→ empty quotes) and strip line
|
|
* and block comments — recognizing each in one walk so a comment marker INSIDE a
|
|
* quoted string is treated as string content, not a comment (and vice versa).
|
|
* Chained regex replaces get the ordering wrong (a quoted slash-slash would hide
|
|
* the rest of the line), which is exactly the bypass this avoids.
|
|
*/
|
|
function stripOverpassNoise(src) {
|
|
let out = '';
|
|
let i = 0;
|
|
const n = src.length;
|
|
while (i < n) {
|
|
const c = src[i];
|
|
if (c === '"' || c === "'") {
|
|
const quote = c;
|
|
i += 1;
|
|
while (i < n) {
|
|
if (src[i] === '\\') { i += 2; continue; } // escaped char
|
|
if (src[i] === quote) { i += 1; break; } // closing quote
|
|
i += 1;
|
|
}
|
|
out += quote + quote; // collapse the literal to empty quotes
|
|
continue;
|
|
}
|
|
if (c === '/' && src[i + 1] === '*') {
|
|
i += 2;
|
|
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i += 1;
|
|
i += 2;
|
|
out += ' ';
|
|
continue;
|
|
}
|
|
if (c === '/' && src[i + 1] === '/') {
|
|
i += 2;
|
|
while (i < n && src[i] !== '\n') i += 1;
|
|
out += ' ';
|
|
continue;
|
|
}
|
|
out += c;
|
|
i += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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(/(?<!\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);
|
|
}
|
|
|
|
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 };
|