fix(search): geocode through the server, not the browser

- Google's Geocoding web service refuses referrer-restricted keys, so the
  browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted
- add /api/google/geocode and /api/google/reverse-geocode, using the server
  key like Places and Street View already do
- the browser key now needs the Map Tiles API only
This commit is contained in:
Niccolò Saggese committed 2026-09-21 11:30:49 +02:00
1 parent 0dbde1e36c
commit c5b1d41cba
15 files changed
+436 -47

No files matched your search

+1 -1
View File
@@ -400,7 +400,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
};
let overpassCall = 0;
globalThis.fetch = async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({
status: 'OK',
results: [{
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
googlePlacesContextProxy,
validateGeocodeAddress,
geocodeBounds,
} from '../vite.config.js';
const UPSTREAM = 'https://geocode.test/json';
function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) {
const routes = new Map();
googlePlacesContextProxy({
resolveApiKey: () => apiKey,
fetchImpl,
endpoints: { geocode: UPSTREAM },
}).configureServer({
middlewares: { use: (name, handler) => routes.set(name, handler) },
});
return routes;
}
function invokeRoute(handler, { method = 'GET', url = '/' } = {}) {
return new Promise((resolve, reject) => {
const headers = new Map();
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(
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
res,
),
).catch(reject);
});
}
test('geocoder query and viewport bias are validated before Google is paid', () => {
assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), {
ok: false,
error: 'address is required',
});
assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), {
ok: false,
error: 'address is required',
});
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })),
{ ok: false, error: 'address is too long' },
);
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })),
{ ok: true, address: 'Austin' },
);
// A bias only ranks results, so an unusable one is dropped, not refused.
const bias = '30.1,-97.9|30.4,-97.6';
assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias);
for (const bounds of [
'',
'30.1,-97.9',
'30.1,-97.9|30.4',
'north,-97.9|30.4,-97.6',
'91,-97.9|30.4,-97.6',
'30.1,-181|30.4,-97.6',
])
assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null);
});
test('the geocode routes keep the key server-side and answer in Google shape', async () => {
const upstream = [];
const routes = installRoutes({
fetchImpl: async (url) => {
upstream.push(new URL(String(url)));
return {
ok: true,
json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }),
};
},
});
const forward = routes.get('/api/google/geocode');
const reverse = routes.get('/api/google/reverse-geocode');
assert.equal(typeof forward, 'function');
assert.equal(typeof reverse, 'function');
const forwarded = await invokeRoute(forward, {
url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6',
});
assert.equal(forwarded.statusCode, 200);
assert.deepEqual(forwarded.body, {
status: 'OK',
results: [{ place_id: 'fixture' }],
});
assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin');
assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' });
assert.equal(reversed.statusCode, 200);
assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
// The key travels to Google, never back to the page that asked.
assert.ok(!JSON.stringify(forwarded.body).includes('server-secret'));
assert.ok(!JSON.stringify(reversed.body).includes('server-secret'));
// A malformed bias is dropped rather than forwarded.
await invokeRoute(forward, { url: '/?address=Austin&bounds=north' });
assert.equal(upstream.at(-1).searchParams.has('bounds'), false);
});
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: [] }) };
};
const routes = installRoutes({ fetchImpl });
for (const [path, url] of [
['/api/google/geocode', '/?address='],
['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'],
]) {
const response = await invokeRoute(routes.get(path), { url });
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body.results, []);
assert.equal(response.body.status, 'REQUEST_DENIED');
}
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const rejected = await invokeRoute(routes.get(path), {
method: 'POST',
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(rejected.statusCode, 405);
}
// Keyless is an empty capability, not an outage: a 200 the browser adapters
// read as "this provider did not answer", so the chain moves on. It resolves
// ahead of validation, like the Places routes: with nothing configured there
// is no request to validate and no quota to protect.
const keyless = installRoutes({ apiKey: ' ', fetchImpl });
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode'])
for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) {
const response = await invokeRoute(keyless.get(path), { url });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.body, {
configured: false,
status: 'REQUEST_DENIED',
results: [],
error: null,
});
}
assert.deepEqual(upstream, []);
});
+12 -4
View File
@@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the
}
});
test('both Places routes select the intended key and keep it out of responses', async (t) => {
test('every Google route selects the intended key and keeps it out of responses', async (t) => {
const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN };
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, key: options.headers['X-Goog-Api-Key'] });
// Places sends the key as a header; Geocoding's web service takes it as a
// query parameter. Either way it must be the server key, and never the
// browser's copy of it.
calls.push({
url,
key:
options?.headers?.['X-Goog-Api-Key'] ??
new URL(String(url)).searchParams.get('key'),
});
return Response.json({ places: [] });
});
try {
@@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses',
for (const install of ['configureServer', 'configurePreviewServer']) {
const routes = new Map();
googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } });
assert.equal(routes.size, 2);
assert.equal(routes.size, 4);
for (const handler of routes.values()) {
const before = calls.length;
let body;
const res = { setHeader() {}, end(value) { body = value; } };
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
assert.equal(res.statusCode, 200);
assert.ok(!body.includes('server-secret'));
if (expected) {
+14 -9
View File
@@ -40,18 +40,23 @@ export function createDefaultPlaceSearch({
...(selected
? [selected]
: [
// Google answers through the local proxy, which holds the server
// key: its Geocoding web service refuses referrer-restricted keys,
// so calling it from here would force the bundled browser key to be
// left unrestricted (#363). That key still gates the provider,
// because a keyless session must not spend a request to learn it
// has no Google: the proxy's "not configured" reads as an
// unanswered provider, which stops the chain caching the negative
// Photon or Nominatim then produces.
createGoogleGeocoder({
request(query, { bias, signal }) {
const key = resolveApiKey?.();
if (!key) return null;
const url = new URL(
endpoints.geocode ||
'https://maps.googleapis.com/maps/api/geocode/json',
if (!resolveApiKey?.()) return null;
const params = new URLSearchParams({ address: query });
if (bias) params.set('bounds', bias);
return fetchImpl(
`${endpoints.geocode || '/api/google/geocode'}?${params}`,
{ signal },
);
url.searchParams.set('address', query);
url.searchParams.set('key', key);
if (bias) url.searchParams.set('bounds', bias);
return fetchImpl(url.toString(), { signal });
},
}),
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
+7 -8
View File
@@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({
endpoints = {},
} = {}) {
const urls = {
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
// Same-origin like its siblings below: Google's Geocoding web service
// refuses referrer-restricted keys, so the key stays on the server (#363).
reverse: '/api/google/reverse-geocode',
textSearch: '/api/google/text-search',
nearby: '/api/google/nearby-places',
route: '/api/route',
@@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({
},
routeProfiles: ['foot', 'car', 'bike'],
async reverseGeocode(latitude, longitude, options) {
const key = resolveApiKey?.();
if (resolveApiKey && !key) return null;
// The browser key gates the provider without travelling with the
// request: the proxy supplies the key Google actually sees.
if (resolveApiKey && !resolveApiKey()) return null;
return normalizeGoogleReverse(
await json(
urls.reverse,
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
options,
),
await json(urls.reverse, { lat: latitude, lon: longitude }, options),
);
},
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
+1 -1
View File
@@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
const urls = [];
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit();
} });
const result = await service.geocode('Hanoi');
assert.equal(result.place.name, 'Hà Nội');
+3 -3
View File
@@ -3082,7 +3082,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl
const requests = [];
installKeyedFetch(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
}
assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/);
@@ -3107,7 +3107,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl
assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9);
assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9);
// Google is asked first and exactly once; Photon answers unbiased, in one call.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/);
assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased');
@@ -3120,7 +3120,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
// "No Radio station matched" for exactly the places it just resolved, while a
// keyed install placed a station. The label may carry it; the filter may not.
const { calls, dataManager } = radioSelectionHarness();
installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/')
installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode')
? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }
: {
ok: true,