fix(server): gate cost/log endpoints against cross-site requests and ship a full CSP

The cost-bearing endpoints (/api/realtime/token, /api/openai/hud-summary,
/api/google/nearby-places) and the voice debug log refuse cross-site browser
requests: a foreign or opaque Origin, a Sec-Fetch-Site other than
same-origin/none, or proxy forwarding headers. Loopback tools without an
Origin and the LAN opt-in keep working. The dev and preview servers send one
shared Content-Security-Policy, and embed-mode framing rewrites only the
frame-ancestors directive of it.

Salvaged from #242 onto current main.

Co-authored-by: Sameh Khamis <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
authored and Sameh Khamis committed 2026-10-02 19:56:42 -07:00
1 parent 42faed3aea
commit 7aa9bbd3be
11 files changed
+402 -15

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import { admitSameSiteRequest } from '../../../src/localRequestGate.mjs';
/**
* Cross-site request gate for cost-bearing and log endpoints. Feeds the
* request to the pure, unit-tested `admitSameSiteRequest` and answers 403
* itself when the request is refused. Returns true when it has already
* responded (the caller returns); false when the handler should continue.
* Refuses cross-site browser requests (foreign or opaque Origin, a
* Sec-Fetch-Site other than same-origin/none, or reverse-proxy headers) while
* keeping loopback non-browser tools and the HOST=0.0.0.0 LAN opt-in working.
* @param {import('node:http').IncomingMessage} req
* @param {import('node:http').ServerResponse} res
* @returns {boolean} True when a 403 was sent.
*/
export function admitSameSite(req, res) {
const verdict = admitSameSiteRequest({
hostHeader: req.headers?.host,
protocol: req.socket?.encrypted ? 'https:' : 'http:',
origin: req.headers?.origin,
secFetchSite: req.headers?.['sec-fetch-site'],
proxyHeaders: req.headers || {},
});
if (verdict.ok) return false;
res.statusCode = verdict.status;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify({ error: verdict.error }));
return true;
}
/**
* Wrap a middleware handler so cross-site requests are refused with 403
* before the handler runs. Loopback tools and same-origin browser requests
* pass straight through.
* @param {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown} handler
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, next?: Function) => unknown}
*/
export function sameSiteGated(handler) {
return (req, res, next) => {
if (admitSameSite(req, res)) return undefined;
return handler(req, res, next);
};
}
+8 -3
View File
@@ -2,6 +2,7 @@ import { defaultSourceRoot } from './common/source-root.js';
import { handleHudSummary } from './openai/hud-summary.js';
import { createDebugLogHandler } from './openai/debug-log.js';
import { createRealtimeTokenHandler } from './openai/realtime.js';
import { sameSiteGated } from './common/same-site.js';
/**
* Vite plugin: OpenAI Realtime ephemeral client secret.
@@ -15,16 +16,20 @@ function openAiRealtimeProxy({
realtime = {},
} = {}) {
function install(middlewares) {
middlewares.use('/api/openai/hud-summary', handleHudSummary);
// Cost-bearing and log endpoints refuse cross-site browser requests
// (see server/providers/common/same-site.js and SECURITY.md).
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
middlewares.use(
'/api/realtime/debug-log',
createDebugLogHandler({ sourceRoot }),
sameSiteGated(createDebugLogHandler({ sourceRoot })),
);
middlewares.use(
'/api/realtime/token',
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
sameSiteGated(
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
),
);
}
+4
View File
@@ -3,6 +3,7 @@ import {
keylessGooglePlacesResponse,
} from './google-key.js';
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
import { admitSameSite } from '../common/same-site.js';
import {
projectNearbyPlaces,
projectTextSearchPlaces,
@@ -50,6 +51,9 @@ export function googlePlacesContextProxy({
} = {}) {
function install(middlewares) {
middlewares.use('/api/google/nearby-places', async (req, res) => {
// Gate first, like the OpenAI routes: a cross-site caller learns nothing
// about this endpoint's method surface.
if (admitSameSite(req, res)) return;
if (req.method !== 'GET') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');