mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
commit
5a7ce5785e
176 files changed
+19654
-1431
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"\}/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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'));
|
||||
});
|
||||
Reference in new issue
Block a user