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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
committed 2026-10-02 20:00:11 -07:00
1 parent 98b8c6c581
commit bbf4976f5e
5 files changed
+29 -3

No files matched your search

+4
View File
@@ -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`
+2
View File
@@ -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. */
+2 -1
View File
@@ -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;
+20 -2
View File
@@ -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',
+1
View File
@@ -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 [