From 799273b3017523b02b5e6414a4af13d2e1d198a5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:06:06 -0800 Subject: [PATCH] Test the installations gate with the Overpass proxy tests, drop the stray PR body, and correct the fragment --- ...-military-installations-cross-site-gate.md | 10 +- docs/CURRENT-STATE.md | 2 + pr-body.md | 75 --------- src/militaryInstallationsGate.test.mjs | 147 ------------------ src/overpassOffload.test.mjs | 44 +++++- 5 files changed, 47 insertions(+), 231 deletions(-) delete mode 100644 pr-body.md delete mode 100644 src/militaryInstallationsGate.test.mjs diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md index 3665da022..35d9e73e7 100644 --- a/changelog.d/1427-military-installations-cross-site-gate.md +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -1,6 +1,4 @@ -- The military-installations proxy (`/api/military-installations`) now refuses - cross-site browser requests with the same `admitSameSite` gate that protects - the terrain, CCTV, traffic, places, and Celestrak proxies. A cross-site - ``, a foreign `Origin`, an opaque origin, or a reverse-proxy-forwarded - request receives 403 before any Overpass query runs. Same-origin app requests - and bare tool requests (curl, Node harness) continue to work. (Sunil56224972) +- The military-installations proxy (`/api/military-installations`) now refuses + cross-site and reverse-proxy-forwarded browser requests with 403 before any + Overpass work, through the same shared gate as `/api/overpass`. Same-origin + app requests and local tools such as curl still work (@Sunil56224972, #1427). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 84d42cb7b..c771dc02a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5084,6 +5084,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's answers; untagged entries stop serving once an instance is configured. The shared transport guards `/api/overpass` and `/api/military-installations`. +Both query handlers refuse cross-site and forwarded browser requests through +the shared local-request gate, before any rate-limit, cache or upstream work. With no configured upstream, each route serves existing cache data (including expired entries, marked stale with the original cache/retrieval date) without refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`, diff --git a/pr-body.md b/pr-body.md deleted file mode 100644 index 28e73decc..000000000 --- a/pr-body.md +++ /dev/null @@ -1,75 +0,0 @@ -## Summary - -Adds the `admitSameSite` cross-site gate to `/api/military-installations`, as offered in the [#975 merge comment](https://github.com/bilawalsidhu/gods-eye-view/pull/975#issuecomment-6357981946). - -This was the last keyless proxy endpoint without the gate. After this change, every proxy that sends queries upstream now refuses cross-site browser requests before doing any work. - -| Endpoint | Gate before this PR | Gate after | -|----------|-------------------|------------| -| `/api/terrain/heights` | ✅ admitSameSite (#975) | ✅ | -| `/api/cctv/frame/:id` | ✅ admitSameSite (#922) | ✅ | -| `/api/traffic/*` | ✅ admitSameSite | ✅ | -| `/api/google/*` | ✅ admitSameSite | ✅ | -| `/api/gemini/*` | ✅ admitSameSite | ✅ | -| `/api/space/satellites` | ✅ admitSameSite | ✅ | -| `/api/overpass` | ✅ admitSameSite | ✅ | -| `/api/military-installations` | ❌ **none** | ✅ admitSameSite | - -## The problem - -A cross-site page (or an `` tag from any website) can query `/api/military-installations` and trigger upstream Overpass requests on the user's behalf. The endpoint has a rate limiter (90/min), but no cross-site gate — so a hostile page can burn through the user's Overpass quota and exfiltrate the response data. - -Every other proxy in the codebase already refuses these shapes. Military-installations was the gap. - -## The fix - -Two lines in `server/providers/military-installations.js`: - -```diff - import { makeRateLimiter, clientKey } from './common/rate-limit.js'; -+import { admitSameSite } from './common/same-site.js'; -``` - -```diff - if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Method Not Allowed' })); - return; - } -+ if (admitSameSite(req, res)) return; - const url = new URL(req.url, 'http://localhost'); -``` - -Same pattern as terrain.js, cctv.js, traffic.js, google.js, etc. Gate runs before the rate limiter, so refused requests never consume quota. - -`scripts/package-boundaries.json` updated to declare `server/providers/common/same-site.js` and `src/localRequestGate.mjs` as owned modules of `military-installations-provider`. - -## Tests - -New file: `src/militaryInstallationsGate.test.mjs` — 4 integration tests following the `terrainHeightsBounds.test.mjs` pattern: - -``` -✔ cross-site browser requests are refused with 403 (2.4ms) -✔ same-origin browser request is admitted (not 403) (5.3ms) -✔ bare tool request (no Origin, no Sec-Fetch) is admitted (0.9ms) -✔ proxied same-origin requests are refused (proxy headers present) (0.5ms) -``` - -Tested shapes: -- `Sec-Fetch-Site: cross-site` (the `` case) → 403 -- `Sec-Fetch-Site: same-site` (cross-origin sibling) → 403 -- `Origin: https://evil.example` (fetch from hostile page) → 403 -- `Origin: null` (sandboxed iframe) → 403 -- `X-Forwarded-For` + `Sec-Fetch-Site: cross-site` (proxied cross-site) → 403 -- Same-origin with matching `Origin` → admitted -- No `Origin`, no `Sec-Fetch` (curl / Node) → admitted -- Proxy headers with `same-origin` → 403 - -All 4 tests fail on `main` and pass with the fix. - -## Validation - -- `node --test src/localRequestGate.test.mjs src/militaryInstallationsGate.test.mjs`: **18 tests, 18 pass, 0 fail** -- `npm run check:boundaries`: **pass** (military-installations-provider: 15 owned modules) -- `npm run check:import-directions`: pass -- Changelog fragment in `changelog.d/military-installations-cross-site-gate.md` diff --git a/src/militaryInstallationsGate.test.mjs b/src/militaryInstallationsGate.test.mjs deleted file mode 100644 index 4451c384b..000000000 --- a/src/militaryInstallationsGate.test.mjs +++ /dev/null @@ -1,147 +0,0 @@ -// Military-installations cross-site gate — verifies that the admitSameSite -// gate added in this PR actually refuses the same request shapes the terrain -// and CCTV proxies already refuse, and admits same-origin and bare-tool -// requests. -// -// Run with: npm test -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { militaryInstallationsProxy } from '../server/providers/military-installations.js'; - -function install(plugin) { - const routes = new Map(); - plugin.configureServer({ - middlewares: { - use(path, handler) { - routes.set(path, handler); - }, - }, - }); - return routes; -} - -function request( - handler, - { url = '/', method = 'GET', remoteAddress = '127.0.0.1', reqHeaders = {} } = {}, -) { - return new Promise((resolve, reject) => { - const headers = new Map(); - const req = { - method, - 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); - }); -} - -// A valid bbox query string for military-installations. -const VALID_BBOX = '?south=48.8&west=2.2&north=48.9&east=2.4'; -const HOST = 'localhost:5173'; - -test('cross-site browser requests are refused with 403', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - const refused = [ - // from any web page - { host: HOST, 'sec-fetch-site': 'cross-site' }, - // same-site but cross-origin sibling - { host: HOST, 'sec-fetch-site': 'same-site' }, - // fetch from another page - { host: HOST, origin: 'https://evil.example' }, - // sandboxed frame - { host: HOST, origin: 'null' }, - // behind a reverse proxy with cross-site - { host: HOST, 'x-forwarded-for': '203.0.113.9', 'sec-fetch-site': 'cross-site' }, - ]; - for (const reqHeaders of refused) { - const answer = await request(route, { url: VALID_BBOX, reqHeaders }); - assert.equal( - answer.statusCode, - 403, - `expected 403 for headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, - ); - } -}); - -test('same-origin browser request is admitted (not 403)', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - // Same-origin with matching origin header - const answer = await request(route, { - url: VALID_BBOX, - reqHeaders: { - host: HOST, - origin: `http://${HOST}`, - 'sec-fetch-site': 'same-origin', - }, - }); - // Should get past the gate — may be 200, 429, or 503 depending on Overpass config, - // but never 403. - assert.notEqual(answer.statusCode, 403, 'same-origin request should not be refused'); -}); - -test('bare tool request (no Origin, no Sec-Fetch) is admitted', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - // curl / Node harness — no browser headers at all - const answer = await request(route, { - url: VALID_BBOX, - reqHeaders: { host: HOST }, - }); - assert.notEqual(answer.statusCode, 403, 'bare tool request should not be refused'); -}); - -test('proxied same-origin requests are refused (proxy headers present)', async () => { - const route = install(militaryInstallationsProxy()).get( - '/api/military-installations', - ); - const proxied = [ - { host: HOST, 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, - { host: HOST, 'sec-fetch-site': 'same-origin', 'cf-connecting-ip': '203.0.113.9' }, - { - host: HOST, - 'sec-fetch-site': 'same-origin', - forwarded: 'for=203.0.113.9;proto=https', - via: '1.1 caddy', - }, - ]; - for (const reqHeaders of proxied) { - const answer = await request(route, { url: VALID_BBOX, reqHeaders }); - assert.equal( - answer.statusCode, - 403, - `expected 403 for proxied headers ${JSON.stringify(reqHeaders)}, got ${answer.statusCode}`, - ); - } -}); diff --git a/src/overpassOffload.test.mjs b/src/overpassOffload.test.mjs index 602bab8e4..7ef6b0c2b 100644 --- a/src/overpassOffload.test.mjs +++ b/src/overpassOffload.test.mjs @@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) { Object.assign(req, { method: init.method || 'GET', url: parsed.pathname.slice(mount.length) + parsed.search || '/', - headers: {}, + headers: init.headers || {}, socket: { remoteAddress: '127.0.0.1' }, }); - let status, headers, body; + let status, body; + let headers = {}; await handlers.get(mount)(req, { + // The cross-site gate answers through statusCode/setHeader, not writeHead. + set statusCode(s) { + status = s; + }, + setHeader(name, value) { + headers[name] = value; + }, writeHead(s, h) { status = s; - headers = h; + headers = { ...headers, ...h }; }, end(b) { body = b; @@ -166,6 +174,36 @@ test('zero egress: every Overpass consumer reaches real default handlers, and re ); }); +test('the installations proxy refuses cross-site and forwarded requests before any Overpass work', async (t) => { + env(t); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const handlers = routes(militaryInstallationsProxy()); + const url = + '/api/military-installations?south=48.8&west=2.2&north=48.9&east=2.4'; + for (const headers of [ + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'https://foreign.example' }, + { origin: 'null' }, + { 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, + ]) { + const response = await call(handlers, url, { headers }); + assert.equal(response.status, 403, JSON.stringify(headers)); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + ]) { + const response = await call(handlers, url, { + headers: { host: 'localhost:4173', ...headers }, + }); + assert.notEqual(response.status, 403, JSON.stringify(headers)); + } +}); + test('an unconfigured server is probed once and never sent a feature query', async (t) => { env(t); t.mock.method(globalThis, 'fetch', () =>