From 3d956177ab91cfe9a0927f3a76f579c7be1ad751 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Sat, 12 Sep 2026 16:35:11 -0700 Subject: [PATCH] fix(providers): validate before limiting and tighten capped image reads Apply shared Places validation, owned image chunks, and response-header deadline tests to the provider modules. --- CHANGELOG.md | 8 ++ docs/CURRENT-STATE.md | 15 +++ server/providers/cctv.js | 31 +++-- server/providers/cctv/constants.js | 5 + server/providers/cctv/media.js | 90 +++++++++++--- server/providers/places/google.js | 80 +++++++----- src/data/cctvProxy.test.mjs | 180 ++++++++++++++++++++++++++- src/googlePlacesCoordinates.test.mjs | 91 ++++++++++++++ 8 files changed, 440 insertions(+), 60 deletions(-) create mode 100644 src/googlePlacesCoordinates.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a9828dc..df26ae5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Security + +- Validate configured Google Places coordinates and text queries before rate + limiting or upstream requests; preserve the keyless capability response. +- Bound CCTV media response headers to 15 seconds and cancel error bodies. + Cap buffered snapshot downloads at 16 MiB while streaming. + + - Cancel the active location lookup when its controls are disposed. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e54ee30d2..bc8ae4646 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,20 @@ # God's Eye View Current State +## Places and CCTV request bounds + +With a Google key configured, nearby and text search reject missing, blank, +non-numeric and out-of-range coordinates before the opt-in limiter and upstream +request. Text search also requires a nonblank query. Keyless requests retain +their `configured: false` response. + +CCTV media waits at most 15 seconds for upstream response headers and returns +504 on timeout. Its timer stops when headers arrive, so live bodies can continue +streaming; body idle deadlines are separate from this header deadline. Error +responses are cancelled. Buffered snapshots have a 16 MiB streaming cap; an +oversized image remains an upstream miss and uses the normal fallback chain. +The existing declared media size ceiling remains 64 MiB. + + ## GBFS upstream bounds GBFS refuses upstream redirects and enforces its 5 MiB response cap while diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 135e8a67d..7e60dccad 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -8,6 +8,7 @@ import { buildSyntheticCctvSvg, proxyMediaResponse, fetchCctvImageFromUpstream, + fetchCctvMediaUpstream, } from './cctv/media.js'; import { CCTV_FRAME_FETCH_TIMEOUT_MS } from './cctv/constants.js'; import { googleServerApiKey } from './places/google-key.js'; @@ -216,19 +217,16 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }; const requestRange = req.headers?.range; if (requestRange) upstreamHeaders.Range = requestRange; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15_000); - let upstream; - try { - upstream = await fetch(mediaUrl, { - headers: upstreamHeaders, - signal: controller.signal, - }); - } finally { - clearTimeout(timeoutId); - } + const upstream = await fetchCctvMediaUpstream(mediaUrl, { + headers: upstreamHeaders, + }); const contentType = upstream.headers.get('content-type') || ''; if (!upstream.ok) { + try { + await upstream.body?.cancel(); + } catch { + /* already closed */ + } setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', @@ -278,7 +276,8 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { }); return; } catch (error) { - const timedOut = error?.name === 'AbortError' || error?.name === 'TimeoutError'; + const timedOut = + error?.name === 'AbortError' || error?.name === 'TimeoutError'; setHealth(cameraId, { status: 'degraded', sourceKind: 'upstream', @@ -289,7 +288,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }); - res.end(JSON.stringify({ error: timedOut ? 'Upstream media timeout' : 'Media proxy failed' })); + res.end( + JSON.stringify({ + error: timedOut + ? 'Upstream media timeout' + : 'Media proxy failed', + }), + ); return; } } diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 81e293fdc..528f857a3 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -41,3 +41,8 @@ export const CCTV_FRAME_FETCH_TIMEOUT_MS = 8 * 1000; /** Maximum buffered snapshot size. */ export const CCTV_FRAME_MAX_BODY_BYTES = 16 * 1024 * 1024; + +/** Deadline for upstream response headers; live bodies keep streaming afterward. */ +export const CCTV_MEDIA_FETCH_TIMEOUT_MS = 15 * 1000; +/** Declared size ceiling for fixed media responses. */ +export const CCTV_MEDIA_MAX_BODY_BYTES = 64 * 1024 * 1024; diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index adc052f81..d5e52a449 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -1,6 +1,11 @@ import { Readable } from 'node:stream'; import { hashSeed, escapeXml } from './normalize.js'; -import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_FRAME_MAX_BODY_BYTES } from './constants.js'; +import { + CCTV_FRAME_FETCH_TIMEOUT_MS, + CCTV_FRAME_MAX_BODY_BYTES, + CCTV_MEDIA_FETCH_TIMEOUT_MS, + CCTV_MEDIA_MAX_BODY_BYTES, +} from './constants.js'; /** * Generate a synthetic SVG billboard image for a CCTV camera placeholder. * @@ -120,10 +125,9 @@ export async function proxyMediaResponse( // Cheap defense: reject an upstream that DECLARES an oversized fixed body. // Live MJPEG/HLS streams are unbounded by design and send no content-length, // so they pipe normally (piping streams to the client, never buffering). - const MEDIA_DECLARED_CAP_BYTES = 64 * 1024 * 1024; if ( Number.isFinite(Number(contentLength)) && - Number(contentLength) > MEDIA_DECLARED_CAP_BYTES + Number(contentLength) > CCTV_MEDIA_MAX_BODY_BYTES ) { res.writeHead(502, { 'Content-Type': 'application/json', @@ -169,29 +173,87 @@ export async function proxyMediaResponse( async function readCappedResponseBytes(upstream, maxBytes) { const declared = Number(upstream.headers.get('content-length')); if (Number.isFinite(declared) && declared > maxBytes) { - try { await upstream.body?.cancel(); } catch { /* no-op */ } + try { + await upstream.body?.cancel(); + } catch { + /* no-op */ + } return null; } - if (!upstream.body || typeof upstream.body[Symbol.asyncIterator] !== 'function') { - const buffered = Buffer.from(await upstream.arrayBuffer()); - return buffered.length > maxBytes ? null : buffered; - } + if (!upstream.body) return null; const chunks = []; let total = 0; - for await (const chunk of upstream.body) { - total += chunk.length; - if (total > maxBytes) { - try { await upstream.body.cancel(); } catch { /* no-op */ } + // Every retained chunk is an OWNED copy: a chunk can be a small view over a + // much larger backing ArrayBuffer, and keeping the view would retain that + // whole allocation while the byte accounting only counted the view. + const keep = (chunk) => { + total += chunk.byteLength; + if (total > maxBytes) return false; + chunks.push(Buffer.from(chunk)); + return true; + }; + if (typeof upstream.body[Symbol.asyncIterator] === 'function') { + for await (const chunk of upstream.body) { + if (!keep(chunk)) { + try { + await upstream.body.cancel(); + } catch { + /* no-op */ + } + return null; + } + } + return Buffer.concat(chunks, total); + } + // No async iterator: stream through a reader so the cap still applies while + // reading. A body that cannot be streamed at all is refused rather than + // buffered uncapped — the helper's whole contract is the cap. + const reader = + typeof upstream.body.getReader === 'function' + ? upstream.body.getReader() + : null; + if (!reader) return null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!keep(value)) { + try { + await reader.cancel(); + } catch { + /* no-op */ + } return null; } - chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); } return Buffer.concat(chunks, total); } +export async function fetchCctvMediaUpstream( + url, + { + headers = {}, + fetchImpl = fetch, + timeoutMs = CCTV_MEDIA_FETCH_TIMEOUT_MS, + } = {}, +) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { headers, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + export async function fetchCctvImageFromUpstream( url, - { fetchImpl = fetch, timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, maxBytes = CCTV_FRAME_MAX_BODY_BYTES } = {}, + { + fetchImpl = fetch, + timeoutMs = CCTV_FRAME_FETCH_TIMEOUT_MS, + maxBytes = CCTV_FRAME_MAX_BODY_BYTES, + CCTV_MEDIA_FETCH_TIMEOUT_MS, + CCTV_MEDIA_MAX_BODY_BYTES, + } = {}, ) { if (!url || !/^https?:\/\//i.test(url)) return null; const controller = new AbortController(); diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 8eeebaf24..5a5b309ad 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -21,6 +21,26 @@ function googleRateLimiter() { return _googleRateLimiter; } +export function validatePlacesCoordinates(searchParams) { + const rawLat = searchParams.get('lat'); + const rawLon = searchParams.get('lon'); + if (rawLat === null || rawLon === null || !rawLat.trim() || !rawLon.trim()) { + return { ok: false, error: 'lat and lon are required' }; + } + const latitude = Number(rawLat); + const longitude = Number(rawLon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return { ok: false, error: 'Valid lat and lon are required' }; + } + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + return { + ok: false, + error: 'lat must be within [-90, 90] and lon within [-180, 180]', + }; + } + return { ok: true, latitude, longitude }; +} + /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, @@ -47,6 +67,16 @@ export function googlePlacesContextProxy({ return; } + const requestUrl = new URL(req.url || '', 'http://localhost'); + const coordinates = validatePlacesCoordinates(requestUrl.searchParams); + if (!coordinates.ok) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: coordinates.error, places: [] })); + return; + } + const { latitude, longitude } = coordinates; + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. // Inlined (not the shared helper) so the 429 body keeps this endpoint's // `places: []` contract that the client expects on every error response. @@ -59,24 +89,10 @@ export function googlePlacesContextProxy({ return; } - const requestUrl = new URL(req.url || '', 'http://localhost'); - const latitude = Number(requestUrl.searchParams.get('lat')); - const longitude = Number(requestUrl.searchParams.get('lon')); const radiusM = Math.max( 25, Math.min(5000, Number(requestUrl.searchParams.get('radiusM')) || 250), ); - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ - error: 'Valid lat and lon are required', - places: [], - }), - ); - return; - } try { const response = await fetch( @@ -160,6 +176,25 @@ export function googlePlacesContextProxy({ return; } + const requestUrl = new URL(req.url || '', 'http://localhost'); + const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); + if (!textQuery) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ error: 'q, lat and lon are required', places: [] }), + ); + return; + } + const coordinates = validatePlacesCoordinates(requestUrl.searchParams); + if (!coordinates.ok) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: coordinates.error, places: [] })); + return; + } + const { latitude, longitude } = coordinates; + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN). No-op when unset. // Inlined (like nearby-places) so the 429 body keeps the `places: []` // contract the client expects on every error response. @@ -172,27 +207,10 @@ export function googlePlacesContextProxy({ return; } - const requestUrl = new URL(req.url || '', 'http://localhost'); - const textQuery = String(requestUrl.searchParams.get('q') || '').trim(); - const latitude = Number(requestUrl.searchParams.get('lat')); - const longitude = Number(requestUrl.searchParams.get('lon')); const radiusM = Math.max( 50, Math.min(50000, Number(requestUrl.searchParams.get('radiusM')) || 4000), ); - if ( - !textQuery || - !Number.isFinite(latitude) || - !Number.isFinite(longitude) || - latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 - ) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ error: 'q, lat and lon are required', places: [] }), - ); - return; - } try { const response = await fetch( diff --git a/src/data/cctvProxy.test.mjs b/src/data/cctvProxy.test.mjs index be796a23f..4a6d68e64 100644 --- a/src/data/cctvProxy.test.mjs +++ b/src/data/cctvProxy.test.mjs @@ -1,9 +1,27 @@ +import { CCTV_FRAME_FETCH_TIMEOUT_MS, CCTV_FRAME_MAX_BODY_BYTES, CCTV_MEDIA_FETCH_TIMEOUT_MS, CCTV_MEDIA_MAX_BODY_BYTES } from '../../server/providers/cctv/constants.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream, -} from '../../vite.config.js'; + fetchCctvMediaUpstream, +} from '../../server/providers/cctv/media.js'; + +/** A body that arrives in chunks and never declares a Content-Length. */ +function chunkedImageResponse(chunkBytes, chunkCount, { onChunk = () => {}, onCancel = () => {} } = {}) { + const stream = new ReadableStream({ + async pull(controller) { + if (chunkCount <= 0) { + controller.close(); + return; + } + chunkCount -= 1; + onChunk(); + controller.enqueue(new Uint8Array(chunkBytes)); + }, + cancel: onCancel, + }); + return new Response(stream, { status: 200, headers: { 'Content-Type': 'image/jpeg' } }); +} test('CCTV upstream frame fetch supplies a bounded abort signal', async () => { let observedSignal = null; @@ -36,3 +54,161 @@ test('CCTV upstream frame fetch returns a valid image response', async () => { assert.equal(result?.contentType, 'image/jpeg'); assert.deepEqual(result?.body, Buffer.from([1, 2, 3])); }); + +test('CCTV upstream frame fetch rejects a declared oversize body without draining it', async () => { + const chunkBytes = 64 * 1024; + let pulled = 0; + let cancelled = false; + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: chunkBytes * 4, + fetchImpl: async () => { + const response = chunkedImageResponse(chunkBytes, 64, { + onChunk: () => { pulled += 1; }, + onCancel: () => { cancelled = true; }, + }); + response.headers.set('Content-Length', String(chunkBytes * 64)); + return response; + }, + }); + + // Assert on the byte count, not the object: a regressed proxy returns a + // multi-megabyte Buffer here, and diffing one into the failure report is + // slower than the check it is reporting on. + assert.equal(result?.body?.length ?? null, null, 'a declared oversize snapshot is a miss, not a buffered body'); + assert.equal(cancelled, true, 'the declared cap is enforced by cancelling, not reading'); + // Only the stream's own one-chunk prefetch may have run; the proxy pulls none. + assert.ok(pulled <= 1, `declared cap short-circuits the read, pulled ${pulled} chunks`); +}); + +test('CCTV upstream frame fetch aborts an undeclared body once it crosses the cap', async () => { + const chunkBytes = 64 * 1024; + let pulled = 0; + let cancelled = false; + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: chunkBytes * 4, + // Sixty-four chunks are offered with no Content-Length; a proxy that + // buffers first would take all of them. + fetchImpl: async () => chunkedImageResponse(chunkBytes, 64, { + onChunk: () => { pulled += 1; }, + onCancel: () => { cancelled = true; }, + }), + }); + + assert.equal(result?.body?.length ?? null, null, 'a chunked body over the cap is a miss'); + // Four chunks fit, the fifth crosses the cap, and one more sits in the + // stream's prefetch queue — nothing past that is ever pulled. + assert.ok(pulled <= 6, `stopped reading at the cap, pulled ${pulled} chunks`); + assert.equal(cancelled, true, 'the upstream stream is cancelled, not drained'); +}); + +test('CCTV upstream frame fetch still returns a chunked body under the cap', async () => { + const chunkBytes = 1024; + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: chunkBytes * 8, + fetchImpl: async () => chunkedImageResponse(chunkBytes, 3), + }); + + assert.equal(result?.ok, true); + assert.equal(result?.body?.length, chunkBytes * 3, 'every chunk is reassembled in order'); + assert.ok(CCTV_FRAME_MAX_BODY_BYTES > 0 && CCTV_FRAME_MAX_BODY_BYTES <= CCTV_MEDIA_MAX_BODY_BYTES, + 'the frame cap must be at or under the media route cap — pinned against the real constant'); +}); + +test('CCTV media upstream fetch aborts when response headers never arrive', async () => { + let observedSignal = null; + const startedAt = Date.now(); + await assert.rejects( + fetchCctvMediaUpstream('https://example.com/stream.m3u8', { + timeoutMs: 25, + fetchImpl: (url, { signal }) => new Promise((resolve, reject) => { + observedSignal = signal; + signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + }), + }), + (error) => error.name === 'AbortError', + ); + assert.equal(observedSignal?.aborted, true, 'the header deadline aborts the attempt'); + assert.ok(Date.now() - startedAt < 2_000, 'the deadline is the injected one, not the production one'); + assert.ok(CCTV_MEDIA_FETCH_TIMEOUT_MS >= 10_000, 'production keeps a generous header deadline for slow cameras'); +}); + +test('CCTV media upstream fetch never cuts a stream whose headers arrived in time', async () => { + let observedSignal = null; + const upstream = await fetchCctvMediaUpstream('https://example.com/stream.m3u8', { + timeoutMs: 20, + fetchImpl: async (url, { signal }) => { + observedSignal = signal; + return { ok: true, status: 200, headers: new Map([['content-type', 'video/mp2t']]), body: null }; + }, + }); + assert.equal(upstream.ok, true); + // Well past the header deadline: the timer was cleared at header arrival, + // so the live body is still allowed to flow. + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(observedSignal?.aborted, false, 'a slow body after timely headers is never aborted here'); +}); + +test('CCTV upstream frame fetch keeps a body that lands exactly on the cap', async () => { + const chunkBytes = 512; + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: chunkBytes * 4, + fetchImpl: async () => chunkedImageResponse(chunkBytes, 4), + }); + assert.equal(result?.ok, true, 'exactly-at-cap is under the ceiling, not over it'); + assert.equal(result?.body?.length, chunkBytes * 4); +}); + +test('CCTV upstream frame fetch ignores a garbage Content-Length and still caps the stream', async () => { + const chunkBytes = 1024; + let cancelled = false; + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: chunkBytes * 2, + fetchImpl: async () => { + const response = chunkedImageResponse(chunkBytes, 8, { onCancel: () => { cancelled = true; } }); + response.headers.set('Content-Length', 'not-a-number'); + return response; + }, + }); + assert.equal(result?.body?.length ?? null, null, 'an unparseable length falls back to counting bytes'); + assert.equal(cancelled, true); +}); + +test('CCTV upstream frame fetch retains owned bytes, not the chunk\'s backing allocation', async () => { + const backing = new ArrayBuffer(4 * 1024 * 1024); + const view = new Uint8Array(backing, 8, 3); + view.set([7, 8, 9]); + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: 1024, + fetchImpl: async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(view); + controller.close(); + }, + }), { status: 200, headers: { 'Content-Type': 'image/jpeg' } }), + }); + assert.equal(result?.ok, true); + assert.deepEqual(Array.from(result.body), [7, 8, 9]); + assert.ok(result.body.buffer.byteLength < backing.byteLength, + 'the 4 MB backing allocation is not retained behind a 3-byte frame'); +}); + +test('CCTV upstream frame fetch refuses a body it cannot stream instead of buffering it uncapped', async () => { + const result = await fetchCctvImageFromUpstream('https://example.com/frame.jpg', { + timeoutMs: 1000, + maxBytes: 1024, + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map([['content-type', 'image/jpeg']]), + body: { /* neither async-iterable nor reader-backed */ }, + arrayBuffer: async () => { throw new Error('arrayBuffer must never be used as an uncapped fallback'); }, + }), + }); + assert.equal(result, null); +}); diff --git a/src/googlePlacesCoordinates.test.mjs b/src/googlePlacesCoordinates.test.mjs new file mode 100644 index 000000000..c6789ae05 --- /dev/null +++ b/src/googlePlacesCoordinates.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { googlePlacesContextProxy, validatePlacesCoordinates } from '../server/providers/places/google.js'; + +function installGooglePlacesRoutes() { + const routes = new Map(); + googlePlacesContextProxy({ resolveApiKey: () => process.env.GOOGLE_MAPS_API_KEY }).configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function invokeRoute(handler, { method = 'GET', url = '/', remoteAddress = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { method, url, headers: {}, socket: { remoteAddress } }; + const res = { + statusCode: 200, + setHeader(name, value) { headers.set(String(name).toLowerCase(), String(value)); }, + end(body = '') { + resolve({ statusCode: this.statusCode, headers: Object.fromEntries(headers), body: body ? JSON.parse(String(body)) : null }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +test('validatePlacesCoordinates refuses missing, blank, non-numeric, and out-of-range values', () => { + const check = (query) => validatePlacesCoordinates(new URLSearchParams(query)); + for (const bad of ['', 'lat=30.27', 'lon=-97.74', 'lat=&lon=', 'lat=%20&lon=-97.74', 'lat=abc&lon=-97.74', + 'lat=Infinity&lon=-97.74', 'lat=NaN&lon=0', 'lat=90.001&lon=0', 'lat=-90.001&lon=0', 'lat=0&lon=180.5', 'lat=0&lon=-180.5']) { + assert.equal(check(bad).ok, false, `${bad || '(empty)'} must be refused`); + } + // Inclusive boundaries are real places (the poles, the antimeridian). + assert.deepEqual(check('lat=90&lon=-180'), { ok: true, latitude: 90, longitude: -180 }); + assert.deepEqual(check('lat=-90&lon=180'), { ok: true, latitude: -90, longitude: 180 }); + assert.deepEqual(check('lat=30.27&lon=-97.74'), { ok: true, latitude: 30.27, longitude: -97.74 }); +}); + +test('both Places routes answer bad coordinates with a 400 before the limiter and before Google', async () => { + const previousKey = process.env.GOOGLE_MAPS_API_KEY; + const previousLimit = process.env.GEV_RATELIMIT_GOOGLE_PER_MIN; + const originalFetch = globalThis.fetch; + process.env.GOOGLE_MAPS_API_KEY = 'test-key'; + process.env.GEV_RATELIMIT_GOOGLE_PER_MIN = '1'; + let upstreamCalls = 0; + globalThis.fetch = async () => { + upstreamCalls += 1; + return { + ok: true, + status: 200, + headers: new Map(), + json: async () => ({ places: [] }), + text: async () => '{"places":[]}', + }; + }; + try { + const routes = installGooglePlacesRoutes(); + const nearby = routes.get('/api/google/nearby-places'); + const textSearch = routes.get('/api/google/text-search'); + const badQueries = ['', 'lat=30.27', 'lat=&lon=', 'lat=abc&lon=-97.74', 'lat=999&lon=-200']; + for (const query of badQueries) { + const nearbyResponse = await invokeRoute(nearby, { url: `/?${query}` }); + assert.equal(nearbyResponse.statusCode, 400, `nearby-places must refuse ${query || '(empty)'}`); + assert.deepEqual(nearbyResponse.body.places, []); + const textResponse = await invokeRoute(textSearch, { url: `/?q=capitol&${query}` }); + assert.equal(textResponse.statusCode, 400, `text-search must refuse ${query || '(empty)'}`); + assert.deepEqual(textResponse.body.places, []); + } + const missingQuery = await invokeRoute(textSearch, { url: '/?lat=30.27&lon=-97.74' }); + assert.equal(missingQuery.statusCode, 400, 'text-search still requires q'); + assert.equal(upstreamCalls, 0, 'invalid input never reaches Google'); + + // The limiter allows ONE request per minute per client. None of the + // rejected requests may have consumed it, so the first valid request + // still goes upstream instead of meeting a 429. + const valid = await invokeRoute(nearby, { url: '/?lat=30.27&lon=-97.74' }); + assert.notEqual(valid.statusCode, 429, 'malformed requests must not consume limiter capacity'); + assert.equal(upstreamCalls, 1, 'a valid request reaches Google exactly once'); + } finally { + globalThis.fetch = originalFetch; + if (previousKey === undefined) delete process.env.GOOGLE_MAPS_API_KEY; + else process.env.GOOGLE_MAPS_API_KEY = previousKey; + if (previousLimit === undefined) delete process.env.GEV_RATELIMIT_GOOGLE_PER_MIN; + else process.env.GEV_RATELIMIT_GOOGLE_PER_MIN = previousLimit; + } +});