Merge pull request #1427 from Sunil56224972/security/cross-site-gate-overpass-military

fix(security): add cross-site gate to military-installations proxy
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-02 07:45:16 -08:00
commit bab5157989
5 files changed
+52 -4

No files matched your search

@@ -0,0 +1,4 @@
- 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).
+2
View File
@@ -5086,6 +5086,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`,
+3 -1
View File
@@ -432,7 +432,9 @@
"server/providers/overpass/transport.js", "server/providers/overpass/transport.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/sources/rateLimit.js" "src/sources/rateLimit.js",
"server/providers/common/same-site.js",
"src/localRequestGate.mjs"
], ],
"external": [] "external": []
}, },
@@ -1,4 +1,5 @@
import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSite } from './common/same-site.js';
import { import {
MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_ELEMENT_CAP,
MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES,
@@ -97,6 +98,7 @@ function militaryInstallationsProxy() {
res.end(JSON.stringify({ error: 'Method Not Allowed' })); res.end(JSON.stringify({ error: 'Method Not Allowed' }));
return; return;
} }
if (admitSameSite(req, res)) return;
const url = new URL(req.url, 'http://localhost'); const url = new URL(req.url, 'http://localhost');
const requested = validMilitaryInstallationBox(url.searchParams); const requested = validMilitaryInstallationBox(url.searchParams);
if (!requested) { if (!requested) {
+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', () =>