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', () =>