mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
e6f1dd70a1
commit
9b4ccbff62
4 files changed
+66
-2
No files matched your search
+2
-1
@@ -10,7 +10,8 @@
|
||||
|
||||
- Refuse cross-site browser requests to the cost-bearing and log endpoints
|
||||
(`/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,
|
||||
while loopback tools and LAN use keep working. The dev and preview servers
|
||||
send one shared Content-Security-Policy (James Sumpter, #242).
|
||||
|
||||
+1
-1
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Network exposure — the operator threat model
|
||||
|
||||
@@ -178,6 +178,8 @@ export function googlePlacesContextProxy({
|
||||
// a view-biased Text Search lands on the actual feature. Same key, field
|
||||
// mask, throttle, and `places: []` error contract as nearby-places above.
|
||||
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') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
@@ -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');
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user