From bbf4976f5e1c15bc5bcf484a4ac91d237fe3b70d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 20:00:11 -0700 Subject: [PATCH] fix(server): keep debug-log timestamps server-owned and send nosniff The voice debug log writes the server's timestamp after the client's record, so a record can no longer replace loggedAt. The dev and preview servers also send X-Content-Type-Options: nosniff with their shared browser headers. From the findings in #710. Co-authored-by: Sunil Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++++ build/vite.js | 2 ++ server/providers/openai/debug-log.js | 3 ++- src/tooling/localServices.test.mjs | 22 ++++++++++++++++++++-- src/tooling/viteBuild.test.mjs | 1 + 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77254c956..07ae6916e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- The voice debug log records the server's own time: a record can no longer + supply its `loggedAt`. The dev and preview servers also send + `X-Content-Type-Options: nosniff` (findings by Sunil, #710). + - Keep the Host-header check when binding to all interfaces. `HOST=0.0.0.0` used to accept any Host; the dev and preview servers now accept IP addresses, `localhost` and the LAN hostnames listed in `GEV_ALLOWED_HOSTS` diff --git a/build/vite.js b/build/vite.js index f0966393b..79a8a8619 100644 --- a/build/vite.js +++ b/build/vite.js @@ -36,6 +36,8 @@ export const BROWSER_CSP = [ export const BROWSER_HEADERS = Object.freeze({ 'X-Frame-Options': 'DENY', 'Content-Security-Policy': BROWSER_CSP, + // Responses are only ever used as the type the server declares. + 'X-Content-Type-Options': 'nosniff', }); /** Build browser assets with explicit inputs; never load environment or providers. */ diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index 6d781a31c..c999c5a69 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -80,10 +80,11 @@ function createDebugLogHandler({ sourceRoot = defaultSourceRoot } = {}) { try { const body = await readRequestBody(req, REALTIME_DEBUG_LOG_MAX_BYTES); const record = JSON.parse(body || '{}'); + // The server's own timestamp comes last, so a record cannot supply one. await append( `${JSON.stringify({ - loggedAt: new Date().toISOString(), ...record, + loggedAt: new Date().toISOString(), })}\n`, ); res.statusCode = 204; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 61f25271a..6533f1011 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -391,8 +391,8 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures', error: 'Failed to write Realtime debug log', }); - // The limiter is always on, unlike the opt-in one the cost-bearing routes - // share: 120/min per IP, far above what a voice session writes. + // This limiter has no opt-out, unlike the one the cost-bearing routes share: + // 120/min per IP, far above what a voice session writes. let limited = null; let accepted = 0; for (let n = 0; n < 130 && !limited; n += 1) { @@ -415,6 +415,24 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures', assert.ok(lines.every((line) => JSON.parse(line).loggedAt)); }); +test('a debug-log record cannot supply its own timestamp', async (t) => { + const sourceRoot = root(t); + const handler = install(openAiRealtimeProxy({ sourceRoot })).get( + '/api/realtime/debug-log', + ); + const before = Date.now(); + const response = await request(handler, { + method: 'POST', + body: JSON.stringify({ loggedAt: '1999-01-01T00:00:00.000Z', note: 'x' }), + }); + assert.equal(response.status, 204); + const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl'); + const [line] = readFileSync(file, 'utf8').split('\n').filter(Boolean); + const record = JSON.parse(line); + assert.equal(record.note, 'x'); + assert.ok(Date.parse(record.loggedAt) >= before - 1000); +}); + test('an oversized debug-log request receives the fixed error response', async (t) => { const handler = install(openAiRealtimeProxy({ sourceRoot: root(t) })).get( '/api/realtime/debug-log', diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index c634c731f..7fe1e0673 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -19,6 +19,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); + assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; assert.equal(csp, BROWSER_CSP); for (const directive of [