mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
c5b1d41cba
commit
771c9e5632
2 files changed
+88
-19
No files matched your search
@@ -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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user