Files
gods-eye-view/server/providers/overpass/query.js

199 lines
7.4 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 };