Merge upstream/main (v0.2.1) into pr/mapillary-layer

Brings in 123 commits from main, including the v0.2.0 and v0.2.1 releases
(MCP server and tool catalog, server security hardening, build hygiene).

Conflicts:
- package.json: kept main's script order and added this PR's
  qa:panel-resize and qa:street-level; exports keep both sides (main's
  tools/view entries and this PR's street-level and Mapillary entries).
- src/tooling/viteBuild.test.mjs: kept this PR's Mapillary-token define
  assertion and main's new wildcard-host allowedHosts assertions.
- CHANGELOG.md: main now has release sections; this PR's entries go under
  [Unreleased].

Checks after the merge: doctor, format:check, check:boundaries,
layer-token:check (28 published, 1 new: street-level 0), npm test (5726
pass), build, the Windows job's focused tests (70 pass), qa:panel-resize,
and the keyless street-level gate. The new CSP allows Mapillary (https:
connect/img, blob workers). The keyed gate stops at coverage locally
because the dev .env Mapillary token is invalid (upstream 403), not because
of the merge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-03 13:29:32 +01:00
commit 5a7ce5785e
176 files changed
+19654 -1431

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { request as httpRequest } from 'node:http';
import { fileURLToPath } from 'node:url';
import {
DEFAULT_ALLOWED_HOSTS,
hostCheckPlugin,
isAllowedHost,
resolveAllowedHosts,
} from '../../build/allowedHosts.js';
test('uses the restricted local host allowlist by default', () => {
assert.deepEqual(resolveAllowedHosts(), DEFAULT_ALLOWED_HOSTS);
assert.equal(resolveAllowedHosts().includes(true), false);
});
test('adds explicitly configured LAN hostnames without widening the allowlist', () => {
assert.deepEqual(
resolveAllowedHosts('globe.lan, globe.internal ,globe.lan'),
['localhost', '127.0.0.1', 'globe.lan', 'globe.internal'],
);
});
test('ignores empty configured host entries', () => {
assert.deepEqual(resolveAllowedHosts(' , , globe.lan, '), [
'localhost',
'127.0.0.1',
'globe.lan',
]);
});
test('rejects suffix and wildcard entries so every LAN hostname is explicit', () => {
assert.deepEqual(resolveAllowedHosts('.local,*.example,globe.lan'), [
'localhost',
'127.0.0.1',
'globe.lan',
]);
});
test('a Host is allowed by the same rule Vite applies', () => {
const allowed = ['localhost', '127.0.0.1', 'globe.lan', '.corp.example'];
for (const host of [
'localhost:4173',
'LOCALHOST:4173',
'app.localhost',
'127.0.0.1:4173',
'192.168.1.20:4173',
'[::1]:4173',
'globe.lan:4173',
'corp.example',
'a.corp.example:8080',
'office:4173',
])
assert.equal(isAllowedHost(host, allowed, ['office']), true, host);
for (const host of [
undefined,
'',
'evil.example:4173',
'globe.lan.evil.example',
'localhost.evil.example',
'mybox.local:4173',
'[not-an-ip]:4173',
])
assert.equal(isAllowedHost(host, allowed, ['office']), false, String(host));
assert.equal(isAllowedHost('anything.example', true), true);
});
test('the Host check is installed first on the dev and preview servers', () => {
const plugin = hostCheckPlugin();
assert.equal(plugin.enforce, 'pre');
for (const hook of ['configureServer', 'configurePreviewServer']) {
let middleware;
plugin[hook]({
config: {
server: { allowedHosts: ['localhost'], host: 'localhost' },
preview: { allowedHosts: ['localhost'], host: undefined },
},
middlewares: { use: (fn) => (middleware = fn) },
});
const respond = (host) => {
const res = {
status: null,
writeHead(status) {
this.status = status;
},
end() {},
};
let passed = false;
middleware({ headers: { host } }, res, () => (passed = true));
return passed ? 'next' : res.status;
};
assert.equal(respond('localhost:4173'), 'next', hook);
assert.equal(respond('evil.example:4173'), 403, hook);
}
});
test('an unlisted Host cannot reach a route a plugin mounts', async (t) => {
const { createServer } = await import('vite');
const { createBrowserViteConfig } = await import('../../build/vite.js');
const provider = {
name: 'fixture-provider',
configureServer(server) {
server.middlewares.use('/api/fixture', (req, res) => {
res.end('provider answered');
});
},
};
const base = createBrowserViteConfig({ plugins: [provider] });
const vite = await createServer({
...base,
root: fileURLToPath(new URL('../../', import.meta.url)),
configFile: false,
envFile: false,
publicDir: false,
logLevel: 'silent',
// Only this test's plugins: the Host check and the fixture provider.
plugins: base.plugins.filter((plugin) =>
['host-check', 'fixture-provider'].includes(plugin?.name),
),
optimizeDeps: { noDiscovery: true, include: [] },
server: {
...base.server,
host: '127.0.0.1',
port: 0,
hmr: false,
watch: null,
},
});
await vite.listen();
t.after(() => vite.close());
const { port } = vite.httpServer.address();
const get = (host) =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/api/fixture',
headers: { Host: host },
},
(response) => {
response.resume();
resolve(response.statusCode);
},
);
request.on('error', reject);
request.end();
});
assert.equal(await get(`localhost:${port}`), 200);
assert.equal(await get(`127.0.0.1:${port}`), 200);
assert.equal(await get(`evil.example:${port}`), 403);
assert.equal(await get(`mybox.local:${port}`), 403);
});
+54
View File
@@ -139,6 +139,60 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123');
});
test('OpenSky global snapshot attempts are time-limited, retried once, then fall back', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_AUTH_MODE: 'anon',
OPENSKY_USERNAME: undefined,
OPENSKY_PASSWORD: undefined,
});
t.mock.method(console, 'warn', () => {});
const timedOut = () =>
Promise.reject(
new DOMException(
'The operation was aborted due to timeout',
'TimeoutError',
),
);
const load = async (respond) => {
const attempts = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/states/')) {
assert.ok(options.signal instanceof AbortSignal);
attempts.push(url);
return respond(attempts.length);
}
if (url.includes('/lat/'))
return Response.json({
now: Date.now() / 1000,
ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }],
});
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
);
const response = await install(fresh.openSkyProxy())(
'/api/opensky',
'?lat=30&lon=-97',
);
return { response, attempts };
};
const recovered = await load((attempt) =>
attempt === 1
? timedOut()
: Response.json({ time: Math.floor(Date.now() / 1000), states: [] }),
);
assert.equal(recovered.attempts.length, 2);
assert.equal(recovered.response.statusCode, 200);
assert.equal(recovered.response.headers['x-flight-source'], undefined);
const stalled = await load(() => timedOut());
assert.equal(stalled.attempts.length, 2);
assert.equal(stalled.response.statusCode, 200);
assert.equal(stalled.response.headers['x-flight-source'], 'adsb.lol');
});
test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => {
let now = Date.now();
let calls = 0;
+77 -2
View File
@@ -21,6 +21,7 @@ import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
function install(plugin, preview = false) {
const routes = new Map();
@@ -195,6 +196,40 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
});
test('Realtime sessions carry supplied tools, and the standalone voice adds the catalog queries', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
sent.push(JSON.parse(options.body));
return Response.json({ value: 'fixture-ephemeral' });
});
const tools = standaloneVoiceTools();
const response = await request(
install(openAiRealtimeProxy({ realtime: { tools } })).get(
'/api/realtime/token',
),
);
assert.equal(response.status, 200);
assert.deepEqual(sent.at(-1).session.tools, tools);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, names.length);
assert.deepEqual(
tools.slice(0, GEV_REALTIME_TOOLS.length),
GEV_REALTIME_TOOLS,
);
assert.ok(names.includes('get_weather'));
assert.ok(names.includes('military_awareness'));
assert.ok(!names.includes('show_in_gods_eye_view'));
assert.ok(!names.includes('aircraft_in_area'));
assert.ok(!names.includes('get_weather_map'));
// The action of the same name answers satellite passes.
assert.equal(
tools.findLast((tool) => tool.name === 'next_satellite_pass'),
GEV_REALTIME_TOOLS.find((tool) => tool.name === 'next_satellite_pass'),
);
});
test('debug logging resolves each supplied application directory independently', async (t) => {
const first = root(t),
second = root(t);
@@ -356,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) {
@@ -380,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',
@@ -434,3 +487,25 @@ test('the debug log rotates instead of growing without bound', async (t) => {
assert.ok(live + previous < 64 * 1024 * 1024);
assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained');
});
test('the voice instructions name only tools the voice session offers', async () => {
const { coreTools } = await import('../tools/index.js');
const tools = new Set(standaloneVoiceTools().map((tool) => tool.name));
const known = new Set([
...coreTools.map((tool) => tool.name),
...GEV_REALTIME_TOOLS.map((tool) => tool.name),
]);
const named = new Set(
realtimeInstructions().match(/\b[a-z]+(?:_[a-z]+)+\b/g),
);
const missing = [...named].filter(
(name) => known.has(name) && !tools.has(name),
);
assert.deepEqual(missing, []);
});
test('the voice instructions send vessels, however heard, to the ships layer', () => {
const text = realtimeInstructions();
assert.match(text, /"vessels" is easily heard as "visuals"/);
assert.match(text, /set_layer_visibility\{layerId:"ais-live-vessels"\}/);
});
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import * as PANEL_PATHS from '../tools/globePanel.js';
import {
PANEL_BASE,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
panelBuildPlugin,
workerFilesPrelude,
} from '../../build/panel.js';
test('the panel loads the paths the panel build writes', () => {
assert.equal(PANEL_BASE, PANEL_PATHS.PANEL_BASE);
assert.equal(
PANEL_WORKER_PRELUDE_PATH,
PANEL_PATHS.PANEL_WORKER_PRELUDE_PATH,
);
});
test('the panel build is one script and one stylesheet under /panel/', () => {
const config = panelBuildConfig({
plugins: [],
build: { chunkSizeWarningLimit: 1500 },
});
assert.equal(config.base, PANEL_BASE);
assert.equal(config.build.outDir, 'dist/panel');
assert.equal(config.build.cssCodeSplit, false);
assert.equal(config.build.rollupOptions.output.inlineDynamicImports, true);
assert.equal(config.build.chunkSizeWarningLimit, 1500);
});
test('the dev server serves the panel build and nothing outside it', async () => {
const root = await mkdtemp(join(tmpdir(), 'panel-build-'));
try {
await mkdir(join(root, 'out', 'assets'), { recursive: true });
await writeFile(join(root, 'out', 'index.html'), '<p>panel</p>');
await writeFile(join(root, 'out', 'assets', 'a.js'), 'x');
await writeFile(join(root, 'secret.txt'), 'secret');
let middleware;
panelBuildPlugin({ outDir: 'out' }).configureServer({
config: { root },
middlewares: { use: (fn) => (middleware = fn) },
});
const get = (url) =>
new Promise((resolve) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
resolve({ status: this.statusCode, headers: this.headers, body });
},
};
middleware({ url }, res, () => resolve({ passed: true }));
});
const page = await get('/panel/');
assert.equal(String(page.body), '<p>panel</p>');
assert.equal(page.headers['Content-Type'], 'text/html');
const script = await get('/panel/assets/a.js');
assert.equal(script.headers['Content-Type'], 'text/javascript');
assert.equal((await get('/panel/missing.js')).status, 404);
assert.equal((await get('/panel/%ZZ')).status, 400);
assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true });
assert.deepEqual(await get('/other'), { passed: true });
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("Cesium's workers get embedded files from memory and other requests from the network", async () => {
const sent = [];
class FakeRequest extends EventTarget {
open(method, url) {
this.url = url;
}
setRequestHeader() {}
send() {
sent.push(this.url);
}
}
const saved = {
XMLHttpRequest: globalThis.XMLHttpRequest,
ProgressEvent: globalThis.ProgressEvent,
};
globalThis.XMLHttpRequest = FakeRequest;
globalThis.ProgressEvent ??= class extends Event {};
try {
new Function(workerFilesPrelude({ 'Assets/heights.json': '{"a":1}' }))();
const embedded = new XMLHttpRequest();
embedded.open(
'GET',
'https://panel.example/panel/cesium/Assets/heights.json?v=1',
);
embedded.setRequestHeader('Accept', 'application/json');
embedded.responseType = 'text';
const loaded = new Promise((resolve) =>
embedded.addEventListener('load', resolve),
);
embedded.send();
await loaded;
assert.equal(embedded.status, 200);
assert.equal(embedded.response, '{"a":1}');
const other = new XMLHttpRequest();
other.open('GET', 'https://tiles.example/1.png');
other.send();
assert.deepEqual(sent, ['https://tiles.example/1.png']);
} finally {
globalThis.XMLHttpRequest = saved.XMLHttpRequest;
globalThis.ProgressEvent = saved.ProgressEvent;
}
});
+61
View File
@@ -482,3 +482,64 @@ test('the upstream host is pinned: a redirect is refused, not followed', async (
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
});
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
});
let upstream = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstream += 1;
return Response.json({ places: [] });
});
const call = async (route, url, headers) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status) {
this.statusCode = status;
},
end() {},
};
await routes.get(route)(
{
url,
method: 'GET',
headers: { host: 'localhost:4173', ...headers },
socket: { remoteAddress: '127.0.0.1' },
},
res,
);
return res.statusCode;
};
const crossSite = {
origin: 'https://attacker.example',
'sec-fetch-site': 'cross-site',
};
for (const [route, url] of [
['/api/google/nearby-places', '?lat=30&lon=-97'],
['/api/google/text-search', '?q=museum&lat=30&lon=-97'],
]) {
assert.equal(await call(route, url, crossSite), 403, route);
assert.equal(
await call(route, url, { 'sec-fetch-site': 'cross-site' }),
403,
route,
);
}
assert.equal(upstream, 0, 'a refused request never reaches Google');
// The app's own page still gets answers.
assert.equal(
await call('/api/google/text-search', '?q=museum&lat=30&lon=-97', {
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
}),
200,
);
assert.equal(upstream, 1);
});
+160 -18
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createBrowserViteConfig } from '../../build/vite.js';
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
import standaloneConfig, * as compatibility from '../../vite.config.js';
import * as providers from '../../server/providers/local.js';
@@ -12,21 +12,29 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
});
assert.equal(config.plugins[2], plugin);
// The Host check runs before every other plugin's middleware.
assert.equal(config.plugins[0].name, 'host-check');
assert.equal(config.plugins[0].enforce, 'pre');
assert.equal(config.plugins[3], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, [
'localhost',
'127.0.0.1',
'.local',
]);
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
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['Content-Security-Policy'],
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 [
"script-src 'self' 'unsafe-eval' blob:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
);
]) {
assert.ok(csp.includes(directive), directive);
}
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
assert.deepEqual(config.preview.headers, config.server.headers);
assert.deepEqual(config.define, {
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
@@ -38,10 +46,18 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
],
'"MLY|1|abc"',
);
assert.equal(
createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server
.allowedHosts,
true,
// A wildcard bind keeps the Host-header check; LAN names are explicit.
for (const host of ['0.0.0.0', '::'])
assert.deepEqual(
createBrowserViteConfig({ host, port: '4800' }).server.allowedHosts,
['localhost', '127.0.0.1'],
);
assert.deepEqual(
createBrowserViteConfig({
host: '0.0.0.0',
allowedHosts: ['localhost', '127.0.0.1', 'globe.lan'],
}).server.allowedHosts,
['localhost', '127.0.0.1', 'globe.lan'],
);
assert.equal(
createBrowserViteConfig({ host: '::', port: '4800' }).server.port,
@@ -58,7 +74,10 @@ test('build helper does not discover environment values or construct local provi
config.define['import.meta.env.GOOGLE_MAPS_API_KEY'],
undefined,
);
assert.equal(config.plugins.length, 2);
assert.deepEqual(
config.plugins.slice(3).map((plugin) => plugin.name),
['embed-framing', 'panel-build'],
);
} finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = before;
@@ -70,11 +89,15 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
config.plugins.slice(2, -1).map((plugin) => plugin.name),
config.plugins.slice(3, -4).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-2).name, 'gev-key-setup');
assert.equal(config.plugins.at(-1).name, 'api-not-found');
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
});
test('build export resolves in Node and has no browser fallback', async () => {
@@ -85,3 +108,122 @@ test('build export resolves in Node and has no browser fallback', async () => {
);
assert.deepEqual(pkg.exports['./build/vite'], { node: './build/vite.js' });
});
test('only embed-mode documents may be framed, and only by the allowed ancestors', async () => {
const { embedFramingPlugin, isEmbedDocumentRequest } =
await import('../../build/embed-framing.js');
assert.equal(isEmbedDocumentRequest('/?embed=1'), true);
assert.equal(isEmbedDocumentRequest('/index.html?embed=1#v=2'), true);
assert.equal(isEmbedDocumentRequest('/?embed=0'), false);
assert.equal(isEmbedDocumentRequest('/api/x?embed=1'), false);
assert.equal(isEmbedDocumentRequest('/src/main.js?embed=1'), false);
let middleware;
embedFramingPlugin({ ancestors: 'https://a.example' }).configureServer({
middlewares: { use: (handler) => (middleware = handler) },
});
const response = () => {
const headers = new Map();
return {
headers,
setHeader(name, value) {
headers.set(name.toLowerCase(), value);
return this;
},
};
};
const embedded = response();
middleware({ url: '/?embed=1' }, embedded, () => {});
// The server's protections, written later at send time.
embedded.setHeader('X-Frame-Options', 'DENY');
embedded.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
embedded.setHeader('Content-Type', 'text/html');
assert.deepEqual(Object.fromEntries(embedded.headers), {
'content-security-policy': 'frame-ancestors https://a.example',
'content-type': 'text/html',
});
let open;
embedFramingPlugin({ ancestors: '*' }).configureServer({
middlewares: { use: (handler) => (open = handler) },
});
const anywhere = response();
open({ url: '/?embed=1' }, anywhere, () => {});
anywhere.setHeader('X-Frame-Options', 'DENY');
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(anywhere.headers), {});
// A full policy keeps its other directives; only the framing rule changes.
const full = "default-src 'self'; script-src 'self'; frame-ancestors 'none'";
const embeddedFull = response();
middleware({ url: '/?embed=1' }, embeddedFull, () => {});
embeddedFull.setHeader('Content-Security-Policy', full);
assert.equal(
embeddedFull.headers.get('content-security-policy'),
"default-src 'self'; script-src 'self'; frame-ancestors https://a.example",
);
const anywhereFull = response();
open({ url: '/?embed=1' }, anywhereFull, () => {});
anywhereFull.setHeader('Content-Security-Policy', full);
assert.equal(
anywhereFull.headers.get('content-security-policy'),
"default-src 'self'; script-src 'self'",
);
const normal = response();
middleware({ url: '/' }, normal, () => {});
normal.setHeader('X-Frame-Options', 'DENY');
normal.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(normal.headers), {
'x-frame-options': 'DENY',
'content-security-policy': "frame-ancestors 'none'",
});
// Unset, no page may frame even embed-mode documents.
for (const ancestors of ['', ' ']) {
const plugin = embedFramingPlugin({ ancestors });
assert.equal(plugin.name, 'embed-framing');
assert.equal(plugin.configureServer, 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'));
});