diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..15e159ac8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4072,6 +4072,7 @@ are omitted rather than framing the wrong part of the globe. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. +- `/api/opensky`, `/api/adsblol/mil`, and `/api/route` are read-only query proxies and answer `405 Method Not Allowed` to anything but `GET`, as `/api/geocode` and the other query proxies already did. The refusal precedes rate limiting and any upstream call. - Track endpoints: `/api/ais-live/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/opensky-track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/adsblol/trace?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/server/providers/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index fc5ab0bd3..80140bb56 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -68,6 +68,13 @@ export function adsbLolProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/adsblol/mil', async (req, res) => { + // Read-only snapshot endpoint; match the method contract its sibling + // query proxies already declare instead of serving every verb alike. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const now = Date.now(); if (_cache && now - _cacheAt < CACHE_MS) { diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 612d01e11..aa0dad268 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -350,6 +350,14 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { export function openSkyProxy() { const installMiddleware = (server) => { server.middlewares.use('/api/opensky', async (req, res) => { + // Read-only snapshot endpoint: state the method contract the way + // /api/geocode and the other query proxies state it, rather than + // answering any verb with the same body and ignoring the request body. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } try { const requestedMode = normalizeOpenSkyAuthMode( process.env.OPENSKY_AUTH_MODE, diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 42e24ad32..8e837fcd4 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -227,6 +227,13 @@ export function installRouteMiddleware( // only shape that asks the upstream for them. A response WITHOUT steps is // byte for byte what this endpoint has always returned. middlewares.use('/api/route', async (req, res) => { + // The documented shape above is a GET with query parameters; say so, + // the way the other query proxies do, instead of answering any verb. + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } const fail = (msg) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: msg })); diff --git a/src/search/geospatial.test.mjs b/src/search/geospatial.test.mjs index be846bb36..dbedbe53f 100644 --- a/src/search/geospatial.test.mjs +++ b/src/search/geospatial.test.mjs @@ -209,7 +209,11 @@ test('routing middleware uses configured OSRM servers while retaining request bo const request = async (query) => { let payload; await handler( - { url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } }, + { + url: `/?${query}`, + method: 'GET', + socket: { remoteAddress: '127.0.0.1' }, + }, { writeHead() {}, end(body) { diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 38953eff9..c25c445d4 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -381,3 +381,26 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('the aircraft snapshot routes decline a non-GET before any upstream work', async (t) => { + // Both routes answered every verb with the full snapshot, unlike the query + // proxies beside them, so nothing said whether a body was read or ignored. + // A stubbed fetch that throws proves the refusal happens before the handler + // reaches upstream, rather than after paying for the work and discarding it. + t.mock.method(globalThis, 'fetch', async (url) => { + throw new Error(`upstream must not be reached: ${url}`); + }); + const cases = [ + [install(providers.openSkyProxy()), '/api/opensky'], + [install(providers.adsbLolProxy()), '/api/adsblol/mil'], + ]; + for (const [request, route] of cases) { + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request(route, '/', method); + assert.equal(response.statusCode, 405, `${method} ${route}`); + assert.deepEqual(JSON.parse(response.body), { + error: 'Method Not Allowed', + }); + } + } +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 78d311369..968c5a9fa 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -482,3 +482,22 @@ test('the upstream host is pinned: a redirect is refused, not followed', async ( await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01'); assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host'); }); + +test('the routing proxy declines a non-GET before rate limiting or upstream', async (t) => { + // The documented shape is a GET with query parameters, but every verb was + // answered alike. Refuse first: a stubbed fetch that throws shows the + // rejection costs neither an upstream call nor a rate-limit slot. + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('upstream must not be reached'); + }); + for (const method of ['POST', 'PUT', 'DELETE', 'PATCH']) { + const response = await request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + method, + ); + assert.equal(response.statusCode, 405, method); + assert.deepEqual(response.body, { error: 'Method Not Allowed' }); + } +});