fix(server): gate Google text search against cross-site requests

/api/google/text-search spends the same Google quota as nearby-places but
was not behind the cross-site gate. It now refuses cross-site requests
before calling Google, and a test covers both paid Google routes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-10-02 20:16:39 -07:00
1 parent e6f1dd70a1
commit 9b4ccbff62
4 files changed
+66 -2

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@
- Refuse cross-site browser requests to the cost-bearing and log endpoints - Refuse cross-site browser requests to the cost-bearing and log endpoints
(`/api/realtime/token`, `/api/openai/hud-summary`, (`/api/realtime/token`, `/api/openai/hud-summary`,
`/api/google/nearby-places`, `/api/realtime/debug-log`): a foreign or opaque `/api/google/nearby-places`, `/api/google/text-search`,
`/api/realtime/debug-log`): a foreign or opaque
Origin, a cross-site `Sec-Fetch-Site`, or proxy forwarding headers get 403, Origin, a cross-site `Sec-Fetch-Site`, or proxy forwarding headers get 403,
while loopback tools and LAN use keep working. The dev and preview servers while loopback tools and LAN use keep working. The dev and preview servers
send one shared Content-Security-Policy (James Sumpter, #242). send one shared Content-Security-Policy (James Sumpter, #242).
+1 -1
View File
@@ -71,7 +71,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients. - **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. - **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:`: no inline script and no script from any other origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:`: no inline script and no script from any other origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
## Network exposure — the operator threat model ## Network exposure — the operator threat model
+2
View File
@@ -178,6 +178,8 @@ export function googlePlacesContextProxy({
// a view-biased Text Search lands on the actual feature. Same key, field // a view-biased Text Search lands on the actual feature. Same key, field
// mask, throttle, and `places: []` error contract as nearby-places above. // mask, throttle, and `places: []` error contract as nearby-places above.
middlewares.use('/api/google/text-search', async (req, res) => { middlewares.use('/api/google/text-search', async (req, res) => {
// Paid like nearby-places, so gated the same way, first.
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') { if (req.method !== 'GET') {
res.statusCode = 405; res.statusCode = 405;
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
+61
View File
@@ -482,3 +482,64 @@ 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'); await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host'); assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
}); });
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
});
let upstream = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstream += 1;
return Response.json({ places: [] });
});
const call = async (route, url, headers) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status) {
this.statusCode = status;
},
end() {},
};
await routes.get(route)(
{
url,
method: 'GET',
headers: { host: 'localhost:4173', ...headers },
socket: { remoteAddress: '127.0.0.1' },
},
res,
);
return res.statusCode;
};
const crossSite = {
origin: 'https://attacker.example',
'sec-fetch-site': 'cross-site',
};
for (const [route, url] of [
['/api/google/nearby-places', '?lat=30&lon=-97'],
['/api/google/text-search', '?q=museum&lat=30&lon=-97'],
]) {
assert.equal(await call(route, url, crossSite), 403, route);
assert.equal(
await call(route, url, { 'sec-fetch-site': 'cross-site' }),
403,
route,
);
}
assert.equal(upstream, 0, 'a refused request never reaches Google');
// The app's own page still gets answers.
assert.equal(
await call('/api/google/text-search', '?q=museum&lat=30&lon=-97', {
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
}),
200,
);
assert.equal(upstream, 1);
});