mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Test the installations gate with the Overpass proxy tests, drop the stray PR body, and correct the fragment
This commit is contained in:
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)
|
|
||||||
@@ -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
@@ -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`
|
|
||||||
@@ -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}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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', () =>
|
||||||
|
|||||||
Reference in new issue
Block a user