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}`,
+ );
+ }
+});