diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md new file mode 100644 index 000000000..3665da022 --- /dev/null +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -0,0 +1,6 @@ +- 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) diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..28e73decc --- /dev/null +++ b/pr-body.md @@ -0,0 +1,75 @@ +## 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/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..4219b311c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -298,7 +298,9 @@ "server/providers/overpass/transport.js", "src/sources/httpBody.js", "src/i18n/marker.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 7d252b8ed..1b6c82107 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -1,4 +1,5 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, @@ -97,6 +98,7 @@ function militaryInstallationsProxy() { res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } + if (admitSameSite(req, res)) return; const url = new URL(req.url, 'http://localhost'); const requested = validMilitaryInstallationBox(url.searchParams); if (!requested) { diff --git a/src/militaryInstallationsGate.test.mjs b/src/militaryInstallationsGate.test.mjs new file mode 100644 index 000000000..4451c384b --- /dev/null +++ b/src/militaryInstallationsGate.test.mjs @@ -0,0 +1,147 @@ +// 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}`, + ); + } +});