fix(search): bound and sanitize the geocode proxy

- reject blank viewport-bias components before Number() reads them as 0
- give the upstream call a 10s deadline and a 256 KiB capped body read
- return fixed public text on failure instead of the upstream message
This commit is contained in:
Niccolò Saggese committed 2026-09-21 11:56:33 +02:00
1 parent c5b1d41cba
commit 771c9e5632
2 files changed
+88 -19

No files matched your search

+46 -14
View File
@@ -4,6 +4,7 @@ import {
keylessGoogleGeocodeResponse,
} from './google-key.js';
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
import { readResponseJsonCapped } from '../common/http.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
@@ -46,6 +47,16 @@ export function validatePlacesCoordinates(searchParams) {
/** Longest accepted geocoder query. Real place names are far shorter. */
const GEOCODE_MAX_QUERY = 200;
/**
* Upstream deadline and body ceiling for the geocode routes. The deadline stays
* armed through body consumption, so a stalled upstream cannot hold a handler
* open, and the cap bounds a chunked or length-omitted reply. A geocode answer
* is a handful of results; 256 KiB is far above any honest one.
*/
const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000;
const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024;
/** Validate a free-text geocoder query before consuming request quota. */
export function validateGeocodeAddress(searchParams) {
const address = String(searchParams.get('address') || '').trim();
@@ -66,8 +77,12 @@ export function geocodeBounds(searchParams) {
if (!raw) return null;
const corners = raw.split('|');
if (corners.length !== 2) return null;
const numbers = corners.flatMap((corner) => corner.split(',').map(Number));
if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null;
const parts = corners.flatMap((corner) => corner.split(','));
// A blank component must be refused before Number() turns it into 0 and a
// half-written corner like `30.4,` reads as the equator.
if (parts.length !== 4 || parts.some((part) => !part.trim())) return null;
const numbers = parts.map(Number);
if (!numbers.every(Number.isFinite)) return null;
const [swLat, swLon, neLat, neLon] = numbers;
if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null;
if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null;
@@ -379,31 +394,48 @@ export function googlePlacesContextProxy({
for (const [name, value] of Object.entries(query.params))
upstream.searchParams.set(name, value);
upstream.searchParams.set('key', apiKey);
const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS);
const response = await fetchImpl(upstream.toString(), {
redirect: 'error',
signal: deadline,
});
const data = await response.json().catch(() => null);
// An unreadable, oversized or timed-out body is not an answer: it
// falls through to the same shaped failure as a refusal below.
const data = await readResponseJsonCapped(
response,
GEOCODE_MAX_RESPONSE_BYTES,
deadline,
).catch(() => null);
// A body we could not read is an upstream failure, not an answer with
// a status of its own, so it does not borrow the upstream's code.
if (!data) {
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
}),
);
return;
}
res.statusCode = response.ok ? 200 : response.status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'private, max-age=300');
res.end(
JSON.stringify(
data ?? {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
},
),
);
} catch (error) {
res.end(JSON.stringify(data));
} catch {
// Fixed public text: a transport, URL or runtime message would
// describe the server to the browser (SECURITY.md, sanitized errors).
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(
JSON.stringify({
status: 'UNKNOWN_ERROR',
results: [],
error: error?.message || 'Google Geocoding request failed',
error: 'Google Geocoding request failed',
}),
);
}
+42 -5
View File
@@ -73,6 +73,10 @@ test('geocoder query and viewport bias are validated before Google is paid', ()
'north,-97.9|30.4,-97.6',
'91,-97.9|30.4,-97.6',
'30.1,-181|30.4,-97.6',
// A blank component must not read as 0 — that is the equator, not a corner.
'30.1,-97.9|30.4,',
'30.1,|30.4,-97.6',
'30.1,-97.9| ,-97.6',
])
assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null);
});
@@ -82,10 +86,7 @@ test('the geocode routes keep the key server-side and answer in Google shape', a
const routes = installRoutes({
fetchImpl: async (url) => {
upstream.push(new URL(String(url)));
return {
ok: true,
json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }),
};
return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] });
},
});
const forward = routes.get('/api/google/geocode');
@@ -123,7 +124,7 @@ test('a bad or keyless geocode request never reaches Google', async () => {
const upstream = [];
const fetchImpl = async (url) => {
upstream.push(String(url));
return { ok: true, json: async () => ({ status: 'OK', results: [] }) };
return Response.json({ status: 'OK', results: [] });
};
const routes = installRoutes({ fetchImpl });
@@ -165,3 +166,39 @@ test('a bad or keyless geocode request never reaches Google', async () => {
assert.deepEqual(upstream, []);
});
test('an upstream failure reaches the browser as fixed public text', async () => {
const routes = installRoutes({
fetchImpl: async () => {
throw new Error('connect ECONNREFUSED 10.0.0.7:443');
},
});
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const response = await invokeRoute(routes.get(path), {
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
}
});
test('an oversized upstream body is dropped rather than buffered', async () => {
const routes = installRoutes({
// Two hundred times the 256 KiB ceiling, declared honestly.
fetchImpl: async () =>
Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }),
});
const response = await invokeRoute(routes.get('/api/google/geocode'), {
url: '/?address=Austin',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
});