Merge branch 'feat/voice-geometry' into maintainer fixes

Picks up acd87e6c: the keyless cross-site gate on /api/osm/features
(582bfce6) and main at 93804d5d. SECURITY.md, the format-scope list and
the schema-test comments take the branch's side; the place-search budget
wording from the timeout fix is kept.
This commit is contained in:
Sameh Khamis committed 2026-11-03 09:29:23 -08:00
commit b531dd545b
7 files changed
+21 -12

No files matched your search

+1 -1
View File
@@ -81,7 +81,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`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, `/api/tomtom/*`, backed by its persistent daily tile budget, and `/api/osm/features`, which spends the operator's own Overpass) 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. The keyless map routes (`/api/terrain/heights` and `/api/overpass`) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map needs both to render behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. - **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`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) 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. The keyless map routes (`/api/terrain/heights`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script 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:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script 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.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+4 -4
View File
@@ -742,8 +742,6 @@
"src/sources/protocols.test.mjs", "src/sources/protocols.test.mjs",
"src/voice/actionSchemas.js", "src/voice/actionSchemas.js",
"src/voice/actionSchemas.test.mjs", "src/voice/actionSchemas.test.mjs",
"src/voice/areaCandidateContract.js",
"src/voice/layerActionOwnership.js",
"server/providers/openai/toolDescriptions.js", "server/providers/openai/toolDescriptions.js",
"src/voice/session.js", "src/voice/session.js",
"src/voice/sessionCommands.js", "src/voice/sessionCommands.js",
@@ -1210,6 +1208,9 @@
"src/tooling/nominatimGuardLifecycle.test.mjs", "src/tooling/nominatimGuardLifecycle.test.mjs",
"src/tooling/nominatimLockOwnership.test.mjs", "src/tooling/nominatimLockOwnership.test.mjs",
"src/tooling/nominatimOutlineRoute.test.mjs", "src/tooling/nominatimOutlineRoute.test.mjs",
"server/providers/common/rateLimitKeys.test.mjs",
"src/voice/areaCandidateContract.js",
"src/voice/layerActionOwnership.js",
"src/data/areaGeometry.test.mjs", "src/data/areaGeometry.test.mjs",
"src/data/areaResolver.test.mjs", "src/data/areaResolver.test.mjs",
"src/sources/osmFeatures.test.mjs", "src/sources/osmFeatures.test.mjs",
@@ -1219,6 +1220,5 @@
"src/voice/areaActions.test.mjs", "src/voice/areaActions.test.mjs",
"src/voice/imageryActions.test.mjs", "src/voice/imageryActions.test.mjs",
"src/voice/osmActions.test.mjs", "src/voice/osmActions.test.mjs",
"src/voice/realtimeProtocol.test.mjs", "src/voice/realtimeProtocol.test.mjs"
"server/providers/common/rateLimitKeys.test.mjs"
] ]
@@ -1,6 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { makeRateLimiter, clientKey } from '../common/rate-limit.js'; import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js'; import { admitSameSiteKeyless } from '../common/same-site.js';
import { createBoundedCache } from '../common/boundedCache.js'; import { createBoundedCache } from '../common/boundedCache.js';
import { import {
compileOsmFeatureQuery, compileOsmFeatureQuery,
@@ -201,8 +201,9 @@ export function installOsmFeaturesRoute(
middlewares.use('/api/osm/features', async (req, res) => { middlewares.use('/api/osm/features', async (req, res) => {
const send = sender(res); const send = sender(res);
if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed')); if (req.method !== 'GET') return send(jsonError(405, 'Method Not Allowed'));
// Quota-bearing: a page elsewhere must not spend the operator's Overpass. // A page elsewhere must not spend the operator's Overpass. The keyless
if (admitSameSite(req, res)) return undefined; // gate, as on /api/overpass: voice works behind a reverse proxy too.
if (admitSameSiteKeyless(req, res)) return undefined;
if (!configured()) if (!configured())
return send( return send(
jsonError(503, 'Place search needs a configured Overpass server.', { jsonError(503, 'Place search needs a configured Overpass server.', {
+2 -2
View File
@@ -1037,8 +1037,8 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// S2 GhostMaps extends the generated layer enums; both feature contracts remain. // S2 GhostMaps extends the generated layer enums; both feature contracts remain.
// The layer alias hint leaves out aliases the layer id already spells. // The layer alias hint leaves out aliases the layer id already spells.
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski // The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint. // Pistes extend the generated layer enums and alias hint; voice geometry
// Voice geometry adds the analyst area scopes and the osm-places layer. // adds the analyst area scopes and the osm-places layer.
assert.equal(block.length, 31318, 'serialized tool schema length drifted'); assert.equal(block.length, 31318, 'serialized tool schema length drifted');
assert.equal( assert.equal(
crypto.createHash('sha256').update(block).digest('hex'), crypto.createHash('sha256').update(block).digest('hex'),
+8
View File
@@ -419,6 +419,14 @@ test('a cross-site page cannot spend the operator Overpass', async () => {
'sec-fetch-site': 'same-origin', 'sec-fetch-site': 'same-origin',
}); });
assert.equal(own.status, 200); assert.equal(own.status, 200);
// Like /api/overpass, it works behind a reverse proxy: forwarding headers
// alone are not a refusal.
const proxied = await request('preset=school&bbox=0,0,2,2', undefined, {
host: 'localhost:5173',
'sec-fetch-site': 'same-origin',
'x-forwarded-for': '203.0.113.7',
});
assert.equal(proxied.status, 200);
}); });
test('an upstream refusal is not an empty answer', async () => { test('an upstream refusal is not an empty answer', async () => {
-1
View File
@@ -55,7 +55,6 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst // Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
// area scopes and the osm-places layer; natural-region candidates and the // area scopes and the osm-places layer; natural-region candidates and the
// tightened area tool wording follow review. // tightened area tool wording follow review.
// Sea Marks and Ski Pistes extend the generated layer enums and alias hint.
'd0c5ad733670972713a54cb10676c42b7c6e78834bfff52b473c0e8a45e742c3', 'd0c5ad733670972713a54cb10676c42b7c6e78834bfff52b473c0e8a45e742c3',
); );
}); });
+2 -1
View File
@@ -346,7 +346,8 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
// it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and // it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and
// the analyst area scopes) raised the ceiling to 55,500; tightening the // the analyst area scopes) raised the ceiling to 55,500; tightening the
// AREAS rule and the area tool descriptions left it at 55,263 with an // AREAS rule and the area tool descriptions left it at 55,263 with an
// operator Overpass, a deliberate ~240-character margin. A new tool or rule // operator Overpass; main's Sea Marks and Ski Pistes layers bring it to
// 55,370, ~130 under. A new tool or rule
// has to fit under this ceiling. // has to fit under this ceiling.
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
assert.ok(size <= 55_500, `prompt size ${size}`); assert.ok(size <= 55_500, `prompt size ${size}`);