Merge pull request #975 from Sunil56224972/fix/terrain-same-site-gate-on-686

fix(server): refuse cross-site requests on the terrain-heights route (stacked on #686)
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-19 14:14:54 +01:00
commit 7712ecf6ed
10 files changed
+567 -7

No files matched your search

+1 -1
View File
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **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.
- **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.
- **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.
@@ -0,0 +1,9 @@
- Bound what a caller can grow through `/api/terrain/heights`. Every distinct
5-decimal point was a permanent in-memory entry, a permanent line in an
uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with
nothing limiting the rate. The point cache now holds at most 50,000 entries
and evicts oldest-first, a cache file above
24 MB is not read back, and the route is limited to 90 requests/minute per
client (270 global). Evicting a point costs one refetch, never a wrong
answer, since terrain does not move. Ordinary use is unaffected: a full
`test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686).
@@ -0,0 +1,8 @@
- Cross-site pages can no longer fill the terrain-height cache or spend the
visitor's own `/api/terrain/heights` rate-limit quota through the visitor's
browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or
opaque `Origin` before rate limiting, cache access or upstream work. Unlike
the cost-bearing routes it still answers same-origin requests that arrive
through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or
Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza,
#975).
+23
View File
@@ -1445,6 +1445,18 @@ an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
## Terrain height cache bound
The `/api/terrain/heights` route refuses cross-site browser requests before
rate limiting, cache access or upstream work: a `Sec-Fetch-Site` other than
`same-origin`/`none` (an `<img>` on another page), or a foreign or opaque
`Origin`, gets `403`. The per-client limiter keys on the socket address, which
for such a request is the visitor's own browser, so without the gate any page
could spend the visitor's 90/min quota and leave the app's own lookups on
`429`. Unlike the cost-bearing routes, the terrain gate does not refuse
reverse-proxy forwarding headers: terrain is keyless and needed for rendering,
and a `403` there would send every chunk to the geoid fallback for an instance
served behind the documented reverse proxy. Same-origin app requests,
non-browser local callers and proxied same-origin requests keep working.
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
latitudes outside [-90, 90] for every client before upstream resolution. One
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
@@ -2676,6 +2688,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
> tunings. Ambient overlap includes each sprite's own rendered extent. The
> gated AIS, CCTV, and satellite passes retain an active-emphasis count so a
> settled dim contact always completes its release after tracking ends.
> - **Terrain-height bounds:** because every distinct 5-decimal point is its own
> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own
> upstream call, four limits bound what a caller can grow. Coordinates outside
> WGS-84 range are rejected with `400` at parse (finite is not the same as on
> Earth); the point cache holds at most 50,000 entries, evicting oldest-first
> — terrain does not move, so an evicted point costs one refetch and never a
> wrong answer; a cache file above 24 MB is not read back into memory (the
> ceiling `rocketLaunchesProxy` already applies); and the route carries an
> always-on limiter of 90 requests/minute/client, 270 global. That cap is an
> order of magnitude above measured demand — a full `npm run test:track` run
> drove 10 requests, peaking at 8/min.
> - **Terrain-height resilience:** `/api/terrain/heights` caches canonical
> 5-decimal points individually, reconstructs reordered/overlapping batches
> in exact request order, and refreshes only missing or stale points. Upstream
+2 -1
View File
@@ -1064,5 +1064,6 @@
"src/ui/flyToDataSource.test.mjs",
"scripts/assemble-changelog.mjs",
"src/tooling/changelogFragments.test.mjs",
"src/data/gbfsProxyRateLimit.test.mjs"
"src/data/gbfsProxyRateLimit.test.mjs",
"src/terrainHeightsBounds.test.mjs"
]
+5 -1
View File
@@ -114,7 +114,11 @@
"exports": ["./server/providers/terrain"],
"modules": [
"server/providers/terrain.js",
"src/data/terrainHeightsProxy.js"
"src/data/terrainHeightsProxy.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
],
"external": []
},
+45
View File
@@ -41,3 +41,48 @@ export function sameSiteGated(handler) {
return handler(req, res, next);
};
}
/**
* Proxy header values arrive client-controlled; read one only when it is a
* single scheme token, never a comma-separated chain.
* @param {import('node:http').IncomingMessage} req
* @returns {'http:'|'https:'}
*/
function forwardedProtocol(req) {
if (req.socket?.encrypted) return 'https:';
const proto = String(req.headers?.['x-forwarded-proto'] || '')
.trim()
.toLowerCase();
return proto === 'https' ? 'https:' : 'http:';
}
/**
* Cross-site gate for keyless routes the map needs in order to render
* (`/api/terrain/heights`). It refuses the same cross-site browser shapes as
* `admitSameSite` (a `Sec-Fetch-Site` other than same-origin/none, a foreign
* or opaque `Origin`) but does not refuse reverse-proxy forwarding headers:
* those protect credentials and spend, which a keyless route has neither of,
* and refusing them would break rendering for an instance served behind the
* reverse proxy the README documents. A single `X-Forwarded-Proto` supplies
* the scheme an `Origin` is compared against. That header cannot be attached
* to a cross-site browser request without a CORS preflight this server never
* answers, and `Sec-Fetch-Site` is still enforced regardless.
* @param {import('node:http').IncomingMessage} req
* @param {import('node:http').ServerResponse} res
* @returns {boolean} True when a 403 was sent.
*/
export function admitSameSiteKeyless(req, res) {
const verdict = admitSameSiteRequest({
hostHeader: req.headers?.host,
protocol: forwardedProtocol(req),
origin: req.headers?.origin,
secFetchSite: req.headers?.['sec-fetch-site'],
proxyHeaders: {},
});
if (verdict.ok) return false;
res.statusCode = verdict.status;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ error: verdict.error }));
return true;
}
+99 -4
View File
@@ -8,6 +8,8 @@ import {
terrainPointKey,
validTerrainResult,
} from '../../src/data/terrainHeightsProxy.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
/**
* Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height
@@ -20,11 +22,31 @@ import {
* Only missing/stale points go upstream; the response is rebuilt in exact
* request order. Larger requests are chunked sequentially, and one failing
* chunk does not discard the chunks that resolved.
*
* Bounds, because every distinct 5dp point is its own key: coordinates must be
* in WGS-84 range (rejected at parse), the point cache has an entry ceiling
* with oldest-first eviction, an over-large cache file is not read back, and
* the route carries an always-on per-client limiter. The route also refuses
* cross-site browser requests, so another web page cannot use the visitor's
* browser to fill the cache or spend the visitor's own rate-limit quota.
* Reverse-proxied same-origin requests are admitted: terrain is keyless and
* needed for rendering, and a 403 would send every chunk to the geoid fallback.
*/
export function terrainHeightsProxy() {
export function terrainHeightsProxy(options = {}) {
// Destructured in the body, not the signature: `proxyErrorResponses.test.mjs`
// extracts these proxies by slicing to the first closing brace in column
// zero, so a multi-line parameter list would cut the function in half.
// Overridable so the ceilings can be exercised as behaviour rather than
// asserted as constants; production constructs this with no arguments.
const {
cacheMaxEntries = 50_000,
cacheMaxDiskBytes = 24 * 1024 * 1024,
cachePath,
} = options;
const TTL_MS = 30 * 24 * 3600_000;
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json');
const CACHE_PATH =
cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json');
const CACHE_DIR = path.dirname(CACHE_PATH);
// Sized against measured upstream latency, not the documented page cap.
// Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12,
// a 2x swing within one hour). 256 points therefore costs 22-48 s and blows
@@ -34,9 +56,50 @@ export function terrainHeightsProxy() {
// that range.
const UPSTREAM_CHUNK = 64;
const MAX_POINTS = 2000;
/**
* Entry ceiling for the point cache, and with it the file written from it.
*
* Every distinct 5dp point is a separate key, so without a ceiling the map
* — and the JSON dumped from it verbatim — grow for as long as a caller
* supplies new coordinates. Measured against real use rather than guessed:
* this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in
* 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is
* eight times that working set and ~8 MB on disk, comfortably inside the
* 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file.
*/
const CACHE_MAX_ENTRIES = cacheMaxEntries;
/** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */
const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes;
/**
* Requests/min/client. Terrain is keyless and free, so this guards the point
* cache and the upstream community service rather than a bill. Sized from
* measurement: across a full `npm run test:track` run — the heaviest
* exercise this app has — the route served 10 requests, peaking at 8/min.
* 90/min is an order of magnitude above that, and the band `/api/overpass`
* and `/api/military-installations` already use.
*/
const RATE_PER_MIN = 90;
const allow = makeRateLimiter({
windowMs: 60_000,
max: RATE_PER_MIN,
globalMax: RATE_PER_MIN * 3,
});
/** @type {Map<string, {at:number, result:object}>} keyed by canonical 5dp lon/lat. */
const mem = new Map();
/**
* Drop the oldest entries until the map is back inside its ceiling.
* Oldest-first by fetch time, the same shape `trackBackfillProxies` uses.
* Terrain does not move, so evicting an old point costs one refetch, never
* a wrong answer.
*/
function evictOverflow() {
if (mem.size <= CACHE_MAX_ENTRIES) return;
const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1)
mem.delete(byAge[i][0]);
}
/** @type {Map<string, Promise<Array<object>>>} single-flight per missing-point subset. */
const inflight = new Map();
let diskLoaded = false;
@@ -47,6 +110,21 @@ export function terrainHeightsProxy() {
if (diskLoaded) return;
diskLoaded = true;
try {
// A cache file past the ceiling is not read back into memory: loading it
// would reinstate exactly the unbounded map the ceiling exists to stop.
// The next flush rewrites a bounded one.
//
// "Cannot measure it" is not "too large": if stat is unavailable, fall
// through and let readFile decide, so an absent file behaves exactly as
// it did before this guard existed.
let sizeBytes = null;
try {
sizeBytes = (await fsp.stat(CACHE_PATH)).size;
} catch {
sizeBytes = null;
}
if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES)
throw new Error('cache file too large');
const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8'));
const pointEntries =
parsed?.version === 2 &&
@@ -87,6 +165,7 @@ export function terrainHeightsProxy() {
}
diskDirty = mem.size > 0;
}
evictOverflow();
} catch {
/* no disk cache yet */
}
@@ -157,12 +236,25 @@ export function terrainHeightsProxy() {
const installMiddleware = (server) => {
server.middlewares.use('/api/terrain/heights', async (req, res) => {
// Refuse cross-site browser requests (an <img> or fetch from another
// site) before the limiter, cache or upstream see them. Same-origin app
// requests, non-browser loopback callers (MCP panel_request) and the app
// served behind a reverse proxy pass.
if (admitSameSiteKeyless(req, res)) return;
const send = (status, bodyObj) => {
if (res.headersSent) return;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(bodyObj));
};
try {
if (!allow(clientKey(req))) {
res.writeHead(429, {
'Content-Type': 'application/json',
'Retry-After': '10',
});
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
return;
}
await loadDiskOnce();
const parsedUrl = new URL(req.url || '', 'http://internal');
const rawPoints = parsedUrl.searchParams.get('points');
@@ -187,7 +279,10 @@ export function terrainHeightsProxy() {
fetchMissing: fetchMissingSingleFlight,
ttlMs: TTL_MS,
});
if (outcome.cacheChanged) diskDirty = true;
if (outcome.cacheChanged) {
evictOverflow();
diskDirty = true;
}
if (outcome.upstreamError) {
console.warn(
'[terrain-heights-proxy] refresh incomplete' +
+7
View File
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js';
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
.map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8'))
@@ -23,6 +24,12 @@ function fixture(name, overrides = {}, preview = false) {
const logs = [];
const deps = {
readResponseTextCapped, coalesceProxyRequest,
// The rate-limited proxies reach for these; give them the real ones so a
// fixture request is throttled exactly as production would be.
makeRateLimiter, clientKey,
// terrain.js gates cross-site requests first; fixture requests carry no
// browser headers, so admit them like a loopback tool.
admitSameSiteKeyless: () => false,
path, process: { cwd: () => '/fixture', env: {} },
fsp: {
readFile: async () => { throw new Error('cache absent'); },
+368
View File
@@ -0,0 +1,368 @@
// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates.
//
// Every distinct 5dp lon/lat is its own cache key, its own permanent line in
// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things
// bound that: WGS-84 range at parse, an entry ceiling on the map, a size
// ceiling on the file that is read back, and a per-client request limiter.
//
// These cases drive the real exported route handler. `fetch` is stubbed per
// test and restored after, so a case that reaches upstream fails loudly.
//
// Run with: npm test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
import { terrainHeightsProxy } from '../server/providers/terrain.js';
import { admitSameSite } from '../server/providers/common/same-site.js';
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(path, handler) {
routes.set(path, handler);
},
},
});
return routes;
}
function request(
handler,
{ url = '/', remoteAddress = '127.0.0.1', reqHeaders = {} } = {},
) {
return new Promise((resolve, reject) => {
const headers = new Map();
const req = {
method: 'GET',
url,
headers: reqHeaders,
socket: { remoteAddress },
};
const res = {
statusCode: 200,
headersSent: false,
setHeader(name, value) {
headers.set(String(name).toLowerCase(), String(value));
},
writeHead(status, hdrs = {}) {
this.statusCode = status;
this.headersSent = true;
for (const [name, value] of Object.entries(hdrs))
headers.set(String(name).toLowerCase(), String(value));
},
end(body = '') {
let parsed = null;
try {
parsed = body ? JSON.parse(String(body)) : null;
} catch {
parsed = String(body);
}
resolve({
statusCode: this.statusCode,
headers: Object.fromEntries(headers),
body: parsed,
});
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
function stubFetch(t, impl) {
const original = globalThis.fetch;
globalThis.fetch = impl;
t.after(() => {
globalThis.fetch = original;
});
}
/**
* Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order.
* Records which point strings were actually asked for, so a test can assert on
* cache misses rather than on internal state.
*/
function stubTerrainUpstream(t) {
const asked = [];
stubFetch(t, async (url) => {
const points = decodeURIComponent(
new URL(String(url)).searchParams.get('points') || '',
)
.split(';')
.filter(Boolean);
asked.push(...points);
return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) });
});
return asked;
}
test('a coordinate off the globe is not a cache key', () => {
// Finite but not on Earth — the old guard accepted every one of these.
for (const raw of [
'0,91',
'0,-91',
'181,0',
'-181,0',
'1e9,1e9',
'0,90.00001',
])
assert.equal(parseTerrainPoints(raw), null, raw);
// The poles and the antimeridian are real places and must still parse.
for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26'])
assert.ok(parseTerrainPoints(raw), raw);
});
test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => {
let upstreamCalls = 0;
stubFetch(t, async () => {
upstreamCalls += 1;
return new Response('[]', { status: 200 });
});
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const refused = await request(route, { url: '/?points=0,91' });
assert.equal(refused.statusCode, 400);
assert.equal(
upstreamCalls,
0,
'an impossible coordinate must not cost an upstream call',
);
});
test('one out-of-range point rejects the batch it is hidden in', async (t) => {
stubFetch(t, async () => new Response('[]', { status: 200 }));
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const mixed = await request(route, {
url: '/?points=-97.74,30.26;0,999;-97.75,30.27',
});
assert.equal(mixed.statusCode, 400);
});
test('the point cache stops growing, and an evicted point is simply refetched', async (t) => {
const asked = stubTerrainUpstream(t);
// A ceiling of 3 exercises the same code path 50,000 does, in four requests.
const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get(
'/api/terrain/heights',
);
const point = (i) => `-97.7${i}000,30.26000`;
for (let i = 1; i <= 4; i += 1)
await request(route, { url: `/?points=${point(i)}` });
assert.equal(asked.length, 4, 'four distinct points, four upstream lookups');
// The newest point is still cached...
await request(route, { url: `/?points=${point(4)}` });
assert.equal(asked.length, 4, 'a cached point costs nothing');
// ...and the oldest was evicted, so it costs one refetch — never a wrong
// answer, because terrain does not move.
await request(route, { url: `/?points=${point(1)}` });
assert.equal(asked.length, 5, 'the evicted point was asked for again');
assert.equal(asked[4], point(1));
});
test('an over-large cache file is not read back into memory', async (t) => {
const asked = stubTerrainUpstream(t);
const point = '-97.74000,30.26000';
const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-'));
const cachePath = path.join(dir, 'terrain-heights.json');
t.after(() => rm(dir, { recursive: true, force: true }));
// A cache file holding the answer, but larger than the ceiling allows.
const padded = {
version: 2,
points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } },
_pad: 'x'.repeat(4096),
};
await writeFile(cachePath, JSON.stringify(padded), 'utf8');
const route = install(
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }),
).get('/api/terrain/heights');
await request(route, { url: `/?points=${point}` });
assert.deepEqual(
asked,
[point],
'the oversized file was skipped, so the point was a miss',
);
// The same file under a ceiling that admits it IS used, which is what proves
// the previous assertion came from the size guard and not a broken path.
const admitted = install(
terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }),
).get('/api/terrain/heights');
await request(admitted, { url: `/?points=${point}` });
assert.deepEqual(asked, [point], 'a cache within the ceiling is still read');
});
test('the route refuses a flood of requests from one client', async (t) => {
stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
let refusedAt = 0;
for (let i = 1; i <= 95; i += 1) {
// A different point each time: distinct keys are the growth path.
const lon = (-97.7 - i / 1000).toFixed(5);
const answer = await request(route, { url: `/?points=${lon},30.26` });
if (answer.statusCode === 429) {
refusedAt = i;
break;
}
}
assert.equal(refusedAt, 91, 'the 91st request in a minute is refused');
});
test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const host = 'localhost:5173';
const refused = [
{ host, 'sec-fetch-site': 'cross-site' }, // <img src> from any web page
{ host, 'sec-fetch-site': 'same-site' },
{ host, origin: 'https://evil.example' }, // fetch from another page
{ host, origin: 'null' }, // sandboxed frame
// Behind a reverse proxy the browser's own Sec-Fetch-Site still decides.
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
];
// More refused requests than the 90/min quota, each a distinct new key.
for (let i = 0; i < 100; i += 1) {
const reqHeaders = refused[i % refused.length];
const answer = await request(route, {
url: `/?points=${(-97 - i / 1000).toFixed(5)},30.26`,
reqHeaders,
});
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
}
assert.deepEqual(asked, [], 'nothing reached upstream or the cache');
// The visitor's own app still has its full quota afterwards, and a
// loopback tool that sends no browser headers is admitted too.
const app = await request(route, {
url: '/?points=-97.74,30.26',
reqHeaders: { host, 'sec-fetch-site': 'same-origin' },
});
assert.equal(app.statusCode, 200);
const tool = await request(route, { url: '/?points=-97.75,30.26' });
assert.equal(tool.statusCode, 200);
assert.equal(asked.length, 2);
});
test('the app served behind a reverse proxy still gets terrain', async (t) => {
const asked = stubTerrainUpstream(t);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
const host = 'globe.example.lan';
// What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The
// cost-bearing routes refuse these; terrain is keyless and needed to render,
// so a 403 here would drop every chunk to the geoid fallback.
const proxied = [
{
host,
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
},
{
host,
'sec-fetch-site': 'same-origin',
'cf-connecting-ip': '203.0.113.9',
},
{
host,
'sec-fetch-site': 'same-origin',
forwarded: 'for=203.0.113.9;proto=https',
via: '1.1 caddy',
},
// TLS terminated at the proxy: the page origin is https, the hop is http.
{
host,
origin: `https://${host}`,
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
];
for (const [i, reqHeaders] of proxied.entries()) {
const answer = await request(route, {
url: `/?points=${(-97.8 - i / 1000).toFixed(5)},30.26`,
reqHeaders,
});
assert.equal(answer.statusCode, 200, JSON.stringify(reqHeaders));
}
assert.equal(asked.length, proxied.length);
// Proxied or not, a cross-site page is still refused before any work.
for (const reqHeaders of [
{ host, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' },
{
host,
'x-forwarded-proto': 'https',
origin: 'https://evil.example',
},
// A forwarded scheme does not make an http Origin match an https page.
{ host, 'x-forwarded-proto': 'https', origin: `http://${host}` },
]) {
const answer = await request(route, {
url: '/?points=-97.9,30.26',
reqHeaders,
});
assert.equal(answer.statusCode, 403, JSON.stringify(reqHeaders));
}
assert.equal(asked.length, proxied.length, 'refusals cost no upstream call');
});
test('cost-bearing routes still refuse proxied requests', () => {
// The keyless exemption is scoped to terrain; the shared gate is unchanged.
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
end(body) {
this.body = body;
},
};
const refused = admitSameSite(
{
headers: {
host: 'globe.example.lan',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.9',
},
socket: {},
},
res,
);
assert.equal(refused, true);
assert.equal(res.statusCode, 403);
assert.deepEqual(JSON.parse(res.body), {
error: 'Proxied requests are not accepted',
});
});
test('a second client keeps its own quota', async (t) => {
stubFetch(t, async () =>
Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]),
);
const route = install(terrainHeightsProxy()).get('/api/terrain/heights');
for (let i = 0; i < 90; i += 1)
await request(route, {
url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`,
remoteAddress: '198.51.100.7',
});
const exhausted = await request(route, {
url: '/?points=-99.5,30.26',
remoteAddress: '198.51.100.7',
});
assert.equal(exhausted.statusCode, 429);
const neighbour = await request(route, {
url: '/?points=-99.6,30.26',
remoteAddress: '198.51.100.8',
});
assert.notEqual(neighbour.statusCode, 429);
});