mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(osm): gate place search cross-site and share its slots fairly
From review: - /api/osm/features now refuses cross-site browser requests like the other quota-bearing routes, so a page elsewhere cannot spend the operator's Overpass. SECURITY.md lists the route, its limits and its disk cache. - One client runs one search at a time and two run at once in all, instead of one global slot any single client could hold. - A search gets 45 s across every configured endpoint in turn, answered 504 when it runs out. - A cache write no longer waits on the disk prune; prune() waits until the cache is idle for callers that need it.
This commit is contained in:
1 parent
0b6cdc8e1f
commit
fb74354d30
6 files changed
+156
-27
No files matched your search
+2
-1
@@ -74,13 +74,14 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
||||
- **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body.
|
||||
- **Transit fetches registered feeds only.** `/api/transit/vehicles/<id>` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator.
|
||||
- **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text.
|
||||
- **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 45 s across every configured endpoint in turn. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached.
|
||||
- **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured.
|
||||
- **Consistent API response headers.** Every `/api` response, including errors, partial content, Host refusals and unknown routes, carries `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` (`apiResponseHeadersPlugin` in `build/vite.js`, installed ahead of the Host check). A route that sets its own policy adds it as a further policy; it never replaces this one.
|
||||
- **Response-size caps and timeouts** on proxied responses.
|
||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, `/api/tomtom/*`, backed by its persistent daily tile budget, and `/api/osm/features`, which spends the operator's own Overpass) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless terrain route (`/api/terrain/heights`) uses the same cross-site checks, minus the forwarding-header refusal: it guards only the per-client quota and point cache, not a key or a bill, and the map needs it to render behind a reverse proxy. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
|
||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||
|
||||
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||
|
||||
@@ -121,9 +121,15 @@ export function createBoundedCache({
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await schedulePrune();
|
||||
// In the background: an answer never waits on a 400-file directory scan.
|
||||
// `prune()` returns the pending pass for callers that need it done.
|
||||
schedulePrune();
|
||||
},
|
||||
/** Run a prune and wait until no pass is running or queued. */
|
||||
async prune() {
|
||||
schedulePrune();
|
||||
while (pruning) await pruning;
|
||||
},
|
||||
prune: schedulePrune,
|
||||
get memorySize() {
|
||||
return memory.size;
|
||||
},
|
||||
|
||||
@@ -32,6 +32,8 @@ export const OSM_FEATURE_LIMITS = Object.freeze({
|
||||
maxResponseBytes: 16 * 1024 * 1024,
|
||||
/** Per-upstream wait: a busy Overpass answers these in 10–30 s. */
|
||||
upstreamTimeoutMs: 40_000,
|
||||
/** The whole search, across every configured endpoint in turn. */
|
||||
upstreamBudgetMs: 45_000,
|
||||
});
|
||||
|
||||
/** Tags a returned feature keeps, besides the preset's own. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import { admitSameSite } from '../common/same-site.js';
|
||||
import { createBoundedCache } from '../common/boundedCache.js';
|
||||
import {
|
||||
compileOsmFeatureQuery,
|
||||
@@ -14,6 +15,8 @@ import { fetchOverpassPayload, overpassPayloadIsData } from './transport.js';
|
||||
|
||||
/** Places of interest change slowly: answers are kept a day. */
|
||||
const OSM_FEATURES_TTL_MS = 86_400_000;
|
||||
/** Upstream searches at once, across all clients (one per client). */
|
||||
const OSM_MAX_RUNNING = 2;
|
||||
/** Cache-key version; nothing cached under another version is reused. */
|
||||
const CACHE_VERSION = 'v2';
|
||||
|
||||
@@ -34,10 +37,14 @@ function createOverpassAnswerer({
|
||||
cache,
|
||||
maxResponseBytes,
|
||||
upstreamTimeoutMs,
|
||||
upstreamBudgetMs,
|
||||
maxRunning = OSM_MAX_RUNNING,
|
||||
}) {
|
||||
const inFlight = new Map();
|
||||
// One search per client, a few in all: one client can't hold the route.
|
||||
const runningBy = new Map();
|
||||
let running = 0;
|
||||
return async function answer(key, ql, summarize, signal = null) {
|
||||
return async function answer(key, ql, summarize, signal = null, owner = '') {
|
||||
signal?.throwIfAborted?.();
|
||||
const held = await cache.get(key);
|
||||
signal?.throwIfAborted?.();
|
||||
@@ -49,16 +56,21 @@ function createOverpassAnswerer({
|
||||
operation = null;
|
||||
}
|
||||
if (!operation) {
|
||||
if (running >= 1)
|
||||
if (runningBy.get(owner) || running >= maxRunning)
|
||||
return {
|
||||
...jsonError(
|
||||
503,
|
||||
'Another map search is running — try again shortly.',
|
||||
runningBy.get(owner)
|
||||
? 'Your previous map search is still running — try again shortly.'
|
||||
: 'Another map search is running — try again shortly.',
|
||||
),
|
||||
retryAfter: '3',
|
||||
};
|
||||
running += 1;
|
||||
runningBy.set(owner, (runningBy.get(owner) || 0) + 1);
|
||||
const controller = new AbortController();
|
||||
// The whole search, every configured endpoint in turn, gets one budget.
|
||||
const budget = AbortSignal.timeout(upstreamBudgetMs);
|
||||
operation = { controller, waiters: 0, settled: false, promise: null };
|
||||
const owned = operation;
|
||||
owned.promise = (async () => {
|
||||
@@ -69,7 +81,7 @@ function createOverpassAnswerer({
|
||||
{
|
||||
simplify: keepBody,
|
||||
timeoutMs: upstreamTimeoutMs,
|
||||
signal: controller.signal,
|
||||
signal: AbortSignal.any([controller.signal, budget]),
|
||||
},
|
||||
);
|
||||
if (!overpassPayloadIsData(payload))
|
||||
@@ -95,10 +107,15 @@ function createOverpassAnswerer({
|
||||
controller.signal.throwIfAborted();
|
||||
return { status: 200, body, cache: 'MISS' };
|
||||
} catch {
|
||||
return jsonError(502, 'OpenStreetMap search failed.');
|
||||
return budget.aborted && !controller.signal.aborted
|
||||
? jsonError(504, 'OpenStreetMap took too long to answer.')
|
||||
: jsonError(502, 'OpenStreetMap search failed.');
|
||||
} finally {
|
||||
owned.settled = true;
|
||||
running -= 1;
|
||||
const left = (runningBy.get(owner) || 1) - 1;
|
||||
if (left > 0) runningBy.set(owner, left);
|
||||
else runningBy.delete(owner);
|
||||
if (inFlight.get(key) === owned) inFlight.delete(key);
|
||||
}
|
||||
})();
|
||||
@@ -161,6 +178,7 @@ export function installOsmFeaturesRoute(
|
||||
// Keys each answer to the instance that gave it, as the other Overpass
|
||||
// caches do, so repointing OVERPASS_UPSTREAMS never serves the old one's.
|
||||
upstreamTag = resolveOverpassUpstreamTag,
|
||||
upstreamBudgetMs = OSM_FEATURE_LIMITS.upstreamBudgetMs,
|
||||
cacheRoot = path.join(process.cwd(), '.gev-cache'),
|
||||
featuresCache = createBoundedCache({
|
||||
dir: cacheRoot ? path.join(cacheRoot, 'osm-features') : null,
|
||||
@@ -177,11 +195,14 @@ export function installOsmFeaturesRoute(
|
||||
cache: featuresCache,
|
||||
maxResponseBytes: OSM_FEATURE_LIMITS.maxResponseBytes,
|
||||
upstreamTimeoutMs: OSM_FEATURE_LIMITS.upstreamTimeoutMs,
|
||||
upstreamBudgetMs,
|
||||
});
|
||||
|
||||
middlewares.use('/api/osm/features', async (req, res) => {
|
||||
const send = sender(res);
|
||||
if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed'));
|
||||
// Quota-bearing: a page elsewhere must not spend the operator's Overpass.
|
||||
if (admitSameSite(req, res)) return undefined;
|
||||
if (!configured())
|
||||
return send(
|
||||
jsonError(503, 'Place search needs a configured Overpass server.', {
|
||||
@@ -213,6 +234,7 @@ export function installOsmFeaturesRoute(
|
||||
compiled.ql,
|
||||
(parsed) => summarizeOsmFeatures(parsed, compiled),
|
||||
controller.signal,
|
||||
clientKey(req),
|
||||
);
|
||||
if (!controller.signal.aborted) send(answer);
|
||||
} catch {
|
||||
|
||||
@@ -46,9 +46,11 @@ test('the disk keeps to its entry and byte quotas and drops expired files', asyn
|
||||
now: () => clock,
|
||||
});
|
||||
for (let i = 0; i < 12; i += 1) await cache.set(`k${i}`, 'x'.repeat(100));
|
||||
await cache.prune();
|
||||
let files = await fsp.readdir(dir);
|
||||
assert.ok(files.length <= 5, `${files.length} files`);
|
||||
await cache.set('big', 'y'.repeat(20_000));
|
||||
await cache.prune();
|
||||
const sizes = await Promise.all(
|
||||
(await fsp.readdir(dir)).map(
|
||||
async (name) => (await fsp.stat(path.join(dir, name))).size,
|
||||
@@ -61,6 +63,7 @@ test('the disk keeps to its entry and byte quotas and drops expired files', asyn
|
||||
await fsp.utimes(path.join(dir, name), past, past);
|
||||
clock = Date.now();
|
||||
await cache.set('fresh', 'z');
|
||||
await cache.prune();
|
||||
files = await fsp.readdir(dir);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(await cache.get('k0'), undefined);
|
||||
|
||||
@@ -160,7 +160,12 @@ test('answers are compacted: centres, kept tags, truncation and counts', () => {
|
||||
|
||||
function mount(
|
||||
fetchPayload,
|
||||
{ featuresCache, configured = () => true, upstreamTag } = {},
|
||||
{
|
||||
featuresCache,
|
||||
configured = () => true,
|
||||
upstreamTag,
|
||||
upstreamBudgetMs,
|
||||
} = {},
|
||||
) {
|
||||
const routes = new Map();
|
||||
const writes = [];
|
||||
@@ -181,11 +186,17 @@ function mount(
|
||||
cacheRoot: null,
|
||||
configured,
|
||||
...(upstreamTag ? { upstreamTag } : {}),
|
||||
...(upstreamBudgetMs ? { upstreamBudgetMs } : {}),
|
||||
featuresCache: featuresCache || memoryCache(),
|
||||
},
|
||||
);
|
||||
let client = 0;
|
||||
const start = (search, route = '/api/osm/features') => {
|
||||
const start = (
|
||||
search,
|
||||
route = '/api/osm/features',
|
||||
headers = {},
|
||||
from = null,
|
||||
) => {
|
||||
let req;
|
||||
let res;
|
||||
const promise = new Promise((resolve) => {
|
||||
@@ -193,8 +204,8 @@ function mount(
|
||||
req = Object.assign(new EventEmitter(), {
|
||||
method: 'GET',
|
||||
url: `/?${search}`,
|
||||
headers: {},
|
||||
socket: { remoteAddress: `10.9.0.${client}` },
|
||||
headers,
|
||||
socket: { remoteAddress: from || `10.9.0.${client}` },
|
||||
});
|
||||
res = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
@@ -202,10 +213,13 @@ function mount(
|
||||
res.status = status;
|
||||
res.headers = headers;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
res.headers = { ...(res.headers || {}), [name]: value };
|
||||
},
|
||||
end(body) {
|
||||
res.writableEnded = true;
|
||||
resolve({
|
||||
status: res.status,
|
||||
status: res.status ?? res.statusCode,
|
||||
headers: res.headers,
|
||||
body: JSON.parse(body),
|
||||
});
|
||||
@@ -220,20 +234,18 @@ function mount(
|
||||
},
|
||||
};
|
||||
};
|
||||
const request = (search, route = '/api/osm/features') =>
|
||||
start(search, route).promise;
|
||||
const request = (search, route = '/api/osm/features', headers, from) =>
|
||||
start(search, route, headers, from).promise;
|
||||
return { request, start, writes };
|
||||
}
|
||||
|
||||
test('the route answers, caches, and runs one upstream query at a time', async () => {
|
||||
let release;
|
||||
test('the route answers, caches, and shares upstream slots fairly', async () => {
|
||||
const release = [];
|
||||
let upstream = 0;
|
||||
const { request, writes } = mount(async (body) => {
|
||||
upstream += 1;
|
||||
assert.match(decodeURIComponent(body), /^data=\[out:json\]/);
|
||||
await new Promise((r) => {
|
||||
release = r;
|
||||
});
|
||||
await new Promise((r) => release.push(r));
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
@@ -241,23 +253,81 @@ test('the route answers, caches, and runs one upstream query at a time', async (
|
||||
}),
|
||||
};
|
||||
});
|
||||
const first = request('preset=hospital&bbox=0,0,2,2');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const other = await request('preset=school&bbox=0,0,2,2');
|
||||
assert.equal(other.status, 503, 'a second, different search waits its turn');
|
||||
release();
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
const first = request(
|
||||
'preset=hospital&bbox=0,0,2,2',
|
||||
undefined,
|
||||
{},
|
||||
'10.1.0.1',
|
||||
);
|
||||
await tick();
|
||||
const sameClient = await request(
|
||||
'preset=school&bbox=0,0,2,2',
|
||||
undefined,
|
||||
{},
|
||||
'10.1.0.1',
|
||||
);
|
||||
assert.equal(
|
||||
sameClient.status,
|
||||
503,
|
||||
"one client's second search waits for its first",
|
||||
);
|
||||
const shared = request(
|
||||
'preset=hospital&bbox=0,0,2,2',
|
||||
undefined,
|
||||
{},
|
||||
'10.1.0.2',
|
||||
);
|
||||
const second = request(
|
||||
'preset=school&bbox=0,0,2,2',
|
||||
undefined,
|
||||
{},
|
||||
'10.1.0.3',
|
||||
);
|
||||
await tick();
|
||||
assert.equal(
|
||||
upstream,
|
||||
2,
|
||||
'another client runs alongside; the same search is shared',
|
||||
);
|
||||
const third = await request(
|
||||
'preset=pharmacy&bbox=0,0,2,2',
|
||||
undefined,
|
||||
{},
|
||||
'10.1.0.4',
|
||||
);
|
||||
assert.equal(third.status, 503, 'the global cap holds');
|
||||
release.forEach((r) => r());
|
||||
const answered = await first;
|
||||
assert.equal(answered.status, 200);
|
||||
assert.equal(answered.body.count, 1);
|
||||
assert.equal((await shared).status, 200);
|
||||
assert.equal((await second).status, 200);
|
||||
const again = await request('preset=hospital&bbox=0,0,2,2');
|
||||
assert.equal(again.headers['X-OSM-Cache'], 'HIT');
|
||||
assert.equal(upstream, 1);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(upstream, 2);
|
||||
assert.equal(writes.length, 2);
|
||||
const refused = await request('preset=hospital&bbox=0,0,9,9');
|
||||
assert.equal(refused.status, 400);
|
||||
assert.equal(refused.body.code, 'AREA_TOO_LARGE');
|
||||
});
|
||||
|
||||
test('a hung Overpass is cut off by the overall budget, across every endpoint', async () => {
|
||||
const { request } = mount(
|
||||
(_body, _max, { signal }) =>
|
||||
new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
{ upstreamBudgetMs: 30 },
|
||||
);
|
||||
const started = Date.now();
|
||||
const answer = await request('preset=hospital&bbox=0,0,2,2');
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
assert.equal(answer.status, 504);
|
||||
});
|
||||
|
||||
test('a cached answer is served only by the Overpass instance that gave it', async () => {
|
||||
let tag = 'instance-a';
|
||||
let upstream = 0;
|
||||
@@ -287,6 +357,31 @@ test('a cached answer is served only by the Overpass instance that gave it', asy
|
||||
);
|
||||
});
|
||||
|
||||
test('a cross-site page cannot spend the operator Overpass', async () => {
|
||||
let upstream = 0;
|
||||
const { request } = mount(async () => {
|
||||
upstream += 1;
|
||||
return { status: 200, body: JSON.stringify({ elements: [] }) };
|
||||
});
|
||||
for (const headers of [
|
||||
{ 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' },
|
||||
{ 'sec-fetch-site': 'cross-site' },
|
||||
{ origin: 'https://evil.example' },
|
||||
]) {
|
||||
const refused = await request('preset=hospital&bbox=0,0,2,2', undefined, {
|
||||
host: 'localhost:5173',
|
||||
...headers,
|
||||
});
|
||||
assert.equal(refused.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(upstream, 0, 'no cross-site request reaches Overpass');
|
||||
const own = await request('preset=hospital&bbox=0,0,2,2', undefined, {
|
||||
host: 'localhost:5173',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
});
|
||||
assert.equal(own.status, 200);
|
||||
});
|
||||
|
||||
test('an upstream refusal is not an empty answer', async () => {
|
||||
const { request } = mount(async () => ({
|
||||
status: 429,
|
||||
|
||||
Reference in new issue
Block a user