Test the installations gate with the Overpass proxy tests, drop the stray PR body, and correct the fragment

This commit is contained in:
Sameh Khamis committed 2026-11-02 07:17:39 -08:00
1 parent 31013d2201
commit 799273b301
5 files changed
+47 -231

No files matched your search

@@ -1,6 +1,4 @@
- The military-installations proxy (`/api/military-installations`) now refuses - The military-installations proxy (`/api/military-installations`) now refuses
cross-site browser requests with the same `admitSameSite` gate that protects cross-site and reverse-proxy-forwarded browser requests with 403 before any
the terrain, CCTV, traffic, places, and Celestrak proxies. A cross-site Overpass work, through the same shared gate as `/api/overpass`. Same-origin
`<img>`, a foreign `Origin`, an opaque origin, or a reverse-proxy-forwarded app requests and local tools such as curl still work (@Sunil56224972, #1427).
request receives 403 before any Overpass query runs. Same-origin app requests
and bare tool requests (curl, Node harness) continue to work. (Sunil56224972)
+2
View File
@@ -5084,6 +5084,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's
answers; untagged entries stop serving once an instance is configured. answers; untagged entries stop serving once an instance is configured.
The shared transport guards `/api/overpass` and `/api/military-installations`. 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 With no configured upstream, each route serves existing cache data (including
expired entries, marked stale with the original cache/retrieval date) without expired entries, marked stale with the original cache/retrieval date) without
refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`, refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`,
-75
View File
@@ -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 `<img>` 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 `<img>` 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`
-147
View File
@@ -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 = [
// <img src> 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}`,
);
}
});
+41 -3
View File
@@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) {
Object.assign(req, { Object.assign(req, {
method: init.method || 'GET', method: init.method || 'GET',
url: parsed.pathname.slice(mount.length) + parsed.search || '/', url: parsed.pathname.slice(mount.length) + parsed.search || '/',
headers: {}, headers: init.headers || {},
socket: { remoteAddress: '127.0.0.1' }, socket: { remoteAddress: '127.0.0.1' },
}); });
let status, headers, body; let status, body;
let headers = {};
await handlers.get(mount)(req, { 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) { writeHead(s, h) {
status = s; status = s;
headers = h; headers = { ...headers, ...h };
}, },
end(b) { end(b) {
body = 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) => { test('an unconfigured server is probed once and never sent a feature query', async (t) => {
env(t); env(t);
t.mock.method(globalThis, 'fetch', () => t.mock.method(globalThis, 'fetch', () =>