From 2608f187d36d7bb8f9a104c821e34740ec6fc86e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 20 Sep 2026 17:45:43 -0700 Subject: [PATCH] fix: sanitize rejected Realtime token responses --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 +- server/providers/openai/realtime.js | 20 +++++++++++++------- src/tooling/localServices.test.mjs | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c15563c..99a421a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,7 +133,7 @@ - Expose portable radio, camera-type and regional source helpers; keep HTTP transport separate from record normalization. -- Three proxy paths no longer relay upstream or JS error text to the client. The HUD summary passed OpenAI's own `error.message` through whenever upstream was not ok, carrying request ids and quota wording; the Realtime token route echoed the JS error, which for a network fault names the upstream host; and a failed CCTV media fetch stored the raw errno as the camera's health message, which reaches the screen through `GET /api/cctv/health` rather than through the sanitized response beside it. Logs now name the failure and the upstream status without the text. +- Three proxy paths no longer relay upstream or JS error text to the client. The HUD summary passed OpenAI's own `error.message` through whenever upstream was not ok, carrying request ids and quota wording; the Realtime token route passed through non-success response bodies and echoed JS errors, which can expose upstream details; and a failed CCTV media fetch stored the raw errno as the camera's health message, which reaches the screen through `GET /api/cctv/health` rather than through the sanitized response beside it. Logs now name the failure and the upstream status without the text. - Separate vessel records and feed acquisition from rendering while preserving selection, partial-feed retention, sea-surface placement and request cancellation. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e41946ecd..570d99796 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3573,7 +3573,7 @@ are omitted rather than framing the wrong part of the globe. - A cold OpenSky failure uses the current camera subpoint only to request a cached adsb.lol point fallback capped at 250 nm. A fresh OpenSky response or last-good cache wins; a nominally successful worldwide snapshot more than two minutes old prefers viewport-scoped adsb.lol when available, otherwise the stale source is reported honestly. The fallback is visibly source-labeled and is never presented as a worldwide snapshot. - GBFS proxy refuses upstream redirects (`redirect: 'manual'`; any 3xx becomes a 502 and the redirect target is logged server-side only) and enforces its 5 MB response cap while the body streams, cancelling the upstream read past the cap; CCTV health map is bounded. - Proxy error payloads are sanitized (no internal error details returned to clients). -- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` answers a fixed string when minting fails, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is. +- That holds for the OpenAI and CCTV media paths too: `/api/openai/hud-summary` never relays OpenAI's own `error.message`, `/api/realtime/token` passes successful ephemeral-token responses through but answers with a fixed error when minting fails or upstream rejects the request, and a failed CCTV media fetch stores a fixed camera health `message` — `GET /api/cctv/health` serializes that field and the CCTV panel renders it as a status label, so it is a client surface as much as the response body is. - `OPENAI_API_KEY` is server-side only; the browser receives ephemeral Realtime client secrets from `/api/realtime/token`. - `AISSTREAM_API_KEY` is server-side only; the browser reads the same-origin `/api/ais-live` cache. - `/api/google/nearby-places` keeps the Google key out of Places requests issued for voice scene context. diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 74a2eccb9..b4dfb4b5a 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -132,19 +132,25 @@ function createRealtimeTokenHandler({ }); const body = await response.text(); res.statusCode = response.status; - res.setHeader( - 'Content-Type', - response.headers.get('content-type') || 'application/json', - ); // Which tier/model this secret was actually minted for. The upstream - // body is passed through untouched (the client parses it verbatim), so - // these headers are the authoritative echo — including the case where a - // bogus ?tier= was silently downgraded to standard. + // success body is passed through untouched (the client parses it + // verbatim), so these headers are the authoritative echo — including the + // case where a bogus ?tier= was silently downgraded to standard. res.setHeader('X-GEV-Voice-Tier', tier); res.setHeader('X-GEV-Voice-Model', model); if (requestedTier && !isKnownVoiceTier(requestedTier)) { res.setHeader('X-GEV-Voice-Tier-Fallback', '1'); } + if (!response.ok) { + console.warn(`[realtime-token] upstream HTTP ${response.status}`); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ error: 'Failed to create Realtime token' })); + return; + } + res.setHeader( + 'Content-Type', + response.headers.get('content-type') || 'application/json', + ); res.end(body); } catch { // For a network fault this was a resolver message naming the upstream diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 395dd4f09..ce9d000b6 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -279,6 +279,7 @@ test('Realtime service configuration selects compatible endpoint/model without f url: '/?tier=arbitrary-model&model=other', }); assert.equal(response.status, 200); + assert.deepEqual(response.json(), { value: 'short-lived-fixture' }); assert.equal(response.headers['x-gev-voice-model'], 'configured-model'); assert.equal(response.headers['cache-control'], 'no-store'); assert.doesNotMatch(response.body, /server-fixture|voice\.example/); @@ -303,6 +304,26 @@ test('OpenAI routes answer generically when the upstream or the request fails', assert.equal(summary.body.includes('req_fixture_1234'), false); assert.equal(summary.body.includes('fixture-upstream-secret'), false); + // An HTTP error from the client-secret endpoint carries the same upstream + // detail, while a successful response must still pass the ephemeral secret. + t.mock.restoreAll(); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ error: { message: leak } }, { status: 429 }), + ); + const rejectedToken = await request( + install(openAiRealtimeProxy()).get('/api/realtime/token'), + ); + assert.equal(rejectedToken.status, 429); + assert.equal( + rejectedToken.headers['content-type'], + 'application/json; charset=utf-8', + ); + assert.deepEqual(rejectedToken.json(), { + error: 'Failed to create Realtime token', + }); + assert.equal(rejectedToken.body.includes('req_fixture_1234'), false); + assert.equal(rejectedToken.body.includes('fixture-upstream-secret'), false); + // A network fault surfaced a resolver message naming the upstream host. t.mock.restoreAll(); t.mock.method(globalThis, 'fetch', async () => {