fix(server): let the CSP load the Nepal event's embedded media

The Content-Security-Policy blocked the YouTube player API, the Facebook
video SDK and X's post widget that src/data/bhoteKoshiEmbeddedMedia.js
loads, along with their players and posts in frames. script-src now allows
exactly those three script origins and a new frame-src allows YouTube,
youtube-nocookie, Facebook and X embeds. A test ties the policy to the
frames the media module builds and pins the allowed script origins.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-10-02 20:21:23 -07:00
1 parent 913d45bc07
commit 0646630dec
3 files changed
+54 -3

No files matched your search

+1 -1
View File
@@ -72,7 +72,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **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`) 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. - **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`) 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.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:`: no inline script and no script from any other 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, 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.
## Network exposure — the operator threat model ## Network exposure — the operator threat model
+8 -2
View File
@@ -6,7 +6,9 @@ import { panelBuildPlugin } from './panel.js';
/** /**
* Content-Security-Policy for every document the dev/preview server serves. * Content-Security-Policy for every document the dev/preview server serves.
* No inline script and no foreign script origin is permitted. 'unsafe-eval' is * No inline script is permitted, and the only foreign script origins are the
* three that the Bhote Koshi event's embedded media needs (YouTube, Facebook
* and X), which frame-src matches for their players and posts. 'unsafe-eval' is
* required: Knockout (bundled inside @cesium/widgets) resolves the global * required: Knockout (bundled inside @cesium/widgets) resolves the global
* object with `(0, eval)("this")` at module load, and without it the Cesium * object with `(0, eval)("this")` at module load, and without it the Cesium
* widget never initializes (verified in headless Chrome). It also covers * widget never initializes (verified in headless Chrome). It also covers
@@ -17,7 +19,9 @@ import { panelBuildPlugin } from './panel.js';
*/ */
export const BROWSER_CSP = [ export const BROWSER_CSP = [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-eval' blob:", // The Bhote Koshi event's embedded media: the YouTube player API, the
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js).
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
@@ -25,6 +29,8 @@ export const BROWSER_CSP = [
"connect-src 'self' blob: data: https: wss: ws:", "connect-src 'self' blob: data: https: wss: ws:",
"worker-src 'self' blob:", "worker-src 'self' blob:",
"child-src 'self' blob:", "child-src 'self' blob:",
// The same media's players and posts, which load in frames.
"frame-src 'self' blob: https://www.youtube-nocookie.com https://www.youtube.com https://www.facebook.com https://platform.twitter.com",
"manifest-src 'self'", "manifest-src 'self'",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
+45
View File
@@ -175,3 +175,48 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
assert.equal(plugin.configurePreviewServer, undefined); assert.equal(plugin.configurePreviewServer, undefined);
} }
}); });
test('the CSP admits the event media embeds, and no other script origin', async () => {
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
const directive = (name) =>
BROWSER_CSP.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${name} `))
?.split(/\s+/)
.slice(1) ?? [];
const scripts = directive('script-src');
const frames = directive('frame-src');
// The player API, the Facebook SDK and X's widget script.
for (const origin of [
'https://www.youtube.com',
'https://connect.facebook.net',
'https://platform.twitter.com',
])
assert.ok(scripts.includes(origin), origin);
// Only those three, and never a wildcard or a scheme.
assert.deepEqual(
scripts.filter(
(source) => source.startsWith('http') || source === 'https:',
),
[
'https://www.youtube.com',
'https://connect.facebook.net',
'https://platform.twitter.com',
],
);
assert.ok(!scripts.some((source) => source.includes('*')));
// Every frame the event builds for a YouTube or Facebook source is allowed.
for (const link of [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.facebook.com/facebook/videos/10153231379946729/',
]) {
const source = resolveEmbeddedMediaSource(link);
assert.ok(source, link);
const origin = new URL(embeddedMediaFrameUrl(source, {})).origin;
assert.ok(frames.includes(origin), `${link} -> ${origin}`);
}
// X posts load in X's own frames.
assert.ok(frames.includes('https://platform.twitter.com'));
});