mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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,94 @@
|
||||
/**
|
||||
* Host header allowlist for the Vite dev/preview server. The configuration
|
||||
* factory passes the configured names in; this module reads no environment.
|
||||
*
|
||||
* Binding to a wildcard interface only controls which network interfaces accept
|
||||
* connections. It must not turn into a wildcard Host-header policy: this server
|
||||
* brokers configured API keys through local proxy routes.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export const DEFAULT_ALLOWED_HOSTS = Object.freeze(['localhost', '127.0.0.1']);
|
||||
|
||||
/**
|
||||
* Resolve Vite's allowedHosts setting from an optional comma-separated list.
|
||||
* Empty entries and suffix/wildcard entries are ignored; repeated names keep
|
||||
* their first occurrence. Vite treats leading-dot values as suffix wildcards,
|
||||
* which would defeat an explicit host policy.
|
||||
*
|
||||
* @param {string|undefined|null} configuredHosts
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function resolveAllowedHosts(configuredHosts) {
|
||||
const hosts = String(configuredHosts ?? '')
|
||||
.split(',')
|
||||
.map((host) => host.trim())
|
||||
.filter((host) => host && !host.startsWith('.') && !host.includes('*'));
|
||||
return [...new Set([...DEFAULT_ALLOWED_HOSTS, ...hosts])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Host header names a host the server answers, by Vite's own rule:
|
||||
* IP addresses, `localhost` and `*.localhost` always; then `additional` names
|
||||
* (the configured server host) and `allowedHosts` (a leading dot allows a
|
||||
* domain and its subdomains). `allowedHosts === true` allows every host.
|
||||
*
|
||||
* @param {string|undefined} hostHeader
|
||||
* @param {string[]|true} allowedHosts
|
||||
* @param {string[]} [additional]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowedHost(hostHeader, allowedHosts, additional = []) {
|
||||
if (allowedHosts === true) return true;
|
||||
const host = String(hostHeader ?? '').trim();
|
||||
if (!host) return false;
|
||||
if (host[0] === '[') {
|
||||
const end = host.indexOf(']');
|
||||
return end > 0 && isIP(host.slice(1, end)) === 6;
|
||||
}
|
||||
const colon = host.indexOf(':');
|
||||
const hostname = (colon === -1 ? host : host.slice(0, colon)).toLowerCase();
|
||||
if (isIP(hostname) === 4) return true;
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (additional.includes(hostname)) return true;
|
||||
return (allowedHosts || []).some(
|
||||
(allowed) =>
|
||||
allowed === hostname ||
|
||||
(allowed[0] === '.' &&
|
||||
(allowed.slice(1) === hostname || hostname.endsWith(allowed))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that applies the Host check before every other middleware.
|
||||
* Vite installs its own check after the middleware plugins add in
|
||||
* `configureServer`, so without this the app's `/api` routes would answer any
|
||||
* Host, including a DNS-rebinding name. It runs first (`enforce: 'pre'`) and
|
||||
* uses the same allowed hosts as Vite, on the dev and preview servers.
|
||||
*/
|
||||
export function hostCheckPlugin() {
|
||||
const middleware = (config, preview) => {
|
||||
const allowed = preview
|
||||
? config.preview.allowedHosts
|
||||
: config.server.allowedHosts;
|
||||
const additional = [config.server.host, config.preview.host].filter(
|
||||
(value) => typeof value === 'string' && value,
|
||||
);
|
||||
return (req, res, next) => {
|
||||
if (isAllowedHost(req.headers.host, allowed, additional)) return next();
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('Blocked request. This host is not allowed.');
|
||||
};
|
||||
};
|
||||
return {
|
||||
name: 'host-check',
|
||||
enforce: 'pre',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(middleware(server.config, false));
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(middleware(server.config, true));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Lets chosen pages frame the app in embed mode (`?embed=1`). Every
|
||||
* document keeps the server's `X-Frame-Options: DENY` and
|
||||
* `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names who may
|
||||
* frame embed-mode documents: CSP frame-ancestors sources, or `*` for any
|
||||
* page. A framing page can change what the app shows, so no page may frame
|
||||
* it by default. Other documents, including Provider Settings, are never
|
||||
* framable. "Any" sends no framing restriction at all: `frame-ancestors *`
|
||||
* would still refuse sandboxed frames with an opaque origin.
|
||||
*/
|
||||
|
||||
const FRAMING_HEADERS = new Set(['x-frame-options', 'content-security-policy']);
|
||||
|
||||
/** The directives of a CSP header value, minus any frame-ancestors rule. */
|
||||
function withoutFraming(value) {
|
||||
return String(value ?? '')
|
||||
.split(';')
|
||||
.map((directive) => directive.trim())
|
||||
.filter(Boolean)
|
||||
.filter((directive) => !/^frame-ancestors\b/i.test(directive));
|
||||
}
|
||||
|
||||
/** Whether a request is for an embed-mode document. */
|
||||
export function isEmbedDocumentRequest(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url || '/', 'http://localhost');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
parsed.searchParams.get('embed') === '1' &&
|
||||
(parsed.pathname === '/' || parsed.pathname.endsWith('.html'))
|
||||
);
|
||||
}
|
||||
|
||||
/** Vite plugin applying the embed framing policy on dev and preview servers. */
|
||||
export function embedFramingPlugin({
|
||||
ancestors = process.env.GEV_EMBED_FRAME_ANCESTORS || '',
|
||||
} = {}) {
|
||||
const allowed = ancestors.trim();
|
||||
// Unset: embed documents keep the server's framing protection.
|
||||
if (!allowed) return { name: 'embed-framing' };
|
||||
const anywhere = allowed === '*';
|
||||
const policy = `frame-ancestors ${allowed}`;
|
||||
const install = (server) => {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!isEmbedDocumentRequest(req.url)) return next();
|
||||
// The server's own headers are written when the response is sent, so
|
||||
// intercept them rather than setting ours first.
|
||||
const setHeader = res.setHeader.bind(res);
|
||||
res.setHeader = (name, value) => {
|
||||
const key = String(name).toLowerCase();
|
||||
if (!FRAMING_HEADERS.has(key)) return setHeader(name, value);
|
||||
if (key === 'content-security-policy') {
|
||||
// Keep every other directive of the server's policy (script-src,
|
||||
// connect-src, ...) and only swap the framing rule.
|
||||
const kept = withoutFraming(value);
|
||||
const next = anywhere ? kept : [...kept, policy];
|
||||
return next.length ? setHeader(name, next.join('; ')) : res;
|
||||
}
|
||||
return res;
|
||||
};
|
||||
if (!anywhere) setHeader('Content-Security-Policy', policy);
|
||||
next();
|
||||
});
|
||||
};
|
||||
return {
|
||||
name: 'embed-framing',
|
||||
configureServer: install,
|
||||
configurePreviewServer: install,
|
||||
};
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The MCP Apps panel build. A panel loads the app through its MCP server,
|
||||
* one tool call per file (see src/tools/globePanel.js), so the app must be
|
||||
* few files: one app script, one stylesheet, and Cesium's own script, which
|
||||
* carries its workers and starts them from memory. The build is served at
|
||||
* PANEL_BASE next to the app.
|
||||
*/
|
||||
|
||||
// The panel loads these paths (src/tools/globePanel.js); a test keeps the
|
||||
// two in step.
|
||||
export const PANEL_BASE = '/panel/';
|
||||
/** The script the panel runs ahead of Cesium's workers (workerFilesPrelude). */
|
||||
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
|
||||
export const PANEL_OUT_DIR = 'dist/panel';
|
||||
|
||||
/** A browser Vite config changed to produce the panel build. */
|
||||
export function panelBuildConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
base: PANEL_BASE,
|
||||
build: {
|
||||
...config.build,
|
||||
outDir: PANEL_OUT_DIR,
|
||||
emptyOutDir: true,
|
||||
modulePreload: false,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
...config.build?.rollupOptions,
|
||||
output: { inlineDynamicImports: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
'.css': 'text/css',
|
||||
'.geojsonl': 'application/geo+json-seq',
|
||||
'.glb': 'model/gltf-binary',
|
||||
'.html': 'text/html',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.wasm': 'application/wasm',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xml': 'application/xml',
|
||||
};
|
||||
|
||||
/**
|
||||
* Vite plugin serving the panel build at PANEL_BASE on the dev server, as
|
||||
* the preview server and production builds serve it from the build output.
|
||||
*/
|
||||
export function panelBuildPlugin({ outDir = PANEL_OUT_DIR } = {}) {
|
||||
return {
|
||||
name: 'panel-build',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
const { pathname } = new URL(req.url || '/', 'http://localhost');
|
||||
if (!pathname.startsWith(PANEL_BASE)) return next();
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const { extname, resolve, sep } = await import('node:path');
|
||||
const root = resolve(server.config.root, outDir);
|
||||
let relative;
|
||||
try {
|
||||
relative = decodeURIComponent(pathname.slice(PANEL_BASE.length));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad path');
|
||||
return;
|
||||
}
|
||||
const file = resolve(root, relative || 'index.html');
|
||||
if (file !== root && !file.startsWith(root + sep)) return next();
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
CONTENT_TYPES[extname(file)] || 'application/octet-stream',
|
||||
);
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.statusCode = 404;
|
||||
res.end(
|
||||
relative ? 'Not found' : 'No panel build; run npm run build:panel',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cesium files its workers load themselves, as paths under Cesium's base.
|
||||
* Workers request them from the panel page's own site, which has none of
|
||||
* the app's files, so the panel build embeds them in the workers script.
|
||||
*/
|
||||
export const PANEL_WORKER_FILES = Object.freeze([
|
||||
'Assets/approximateTerrainHeights.json',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A script, run in each worker before Cesium's, that answers the worker's
|
||||
* requests for `files` (Cesium-relative path → text) from memory and lets
|
||||
* every other request through.
|
||||
*/
|
||||
export function workerFilesPrelude(files) {
|
||||
const answer = (FILES) => {
|
||||
const find = (url) => {
|
||||
const path = String(url).split(/[?#]/)[0];
|
||||
for (const name of Object.keys(FILES))
|
||||
if (path.endsWith(`/${name}`)) return FILES[name];
|
||||
return null;
|
||||
};
|
||||
const xhr = XMLHttpRequest.prototype;
|
||||
const { open, send, setRequestHeader } = xhr;
|
||||
xhr.open = function (method, url, ...rest) {
|
||||
this.__gevFile = find(url);
|
||||
if (this.__gevFile === null) return open.call(this, method, url, ...rest);
|
||||
};
|
||||
xhr.setRequestHeader = function (...args) {
|
||||
if (this.__gevFile === null) return setRequestHeader.apply(this, args);
|
||||
};
|
||||
xhr.send = function (body) {
|
||||
const text = this.__gevFile;
|
||||
if (text === null || text === undefined) return send.call(this, body);
|
||||
const type = this.responseType;
|
||||
const response =
|
||||
type === 'json'
|
||||
? JSON.parse(text)
|
||||
: type === 'arraybuffer'
|
||||
? new TextEncoder().encode(text).buffer
|
||||
: text;
|
||||
const state = {
|
||||
readyState: 4,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
response,
|
||||
responseText: typeof response === 'string' ? response : '',
|
||||
getAllResponseHeaders: () => 'content-type: application/json\r\n',
|
||||
getResponseHeader: (name) =>
|
||||
name.toLowerCase() === 'content-type' ? 'application/json' : null,
|
||||
};
|
||||
for (const [name, value] of Object.entries(state))
|
||||
Object.defineProperty(this, name, { configurable: true, value });
|
||||
setTimeout(() => {
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
this.dispatchEvent(new ProgressEvent('load'));
|
||||
this.dispatchEvent(new ProgressEvent('loadend'));
|
||||
});
|
||||
};
|
||||
};
|
||||
return `(${answer})(${JSON.stringify(files)});\n`;
|
||||
}
|
||||
+67
-10
@@ -1,5 +1,50 @@
|
||||
import { applicationHtmlPlugin } from './application-html.js';
|
||||
import cesium from 'vite-plugin-cesium';
|
||||
import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js';
|
||||
import { embedFramingPlugin } from './embed-framing.js';
|
||||
import { panelBuildPlugin } from './panel.js';
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for every document the dev/preview server serves.
|
||||
* 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
|
||||
* object with `(0, eval)("this")` at module load, and without it the Cesium
|
||||
* widget never initializes (verified in headless Chrome). It also covers
|
||||
* Cesium's WASM decoders. `blob:` is required in the built app: Cesium's
|
||||
* bundled workers bootstrap through `importScripts(blob:...)`, which a worker
|
||||
* checks against script-src (the dev server loads them by URL instead).
|
||||
* Widen any other directive only for a real violation.
|
||||
*/
|
||||
export const BROWSER_CSP = [
|
||||
"default-src 'self'",
|
||||
// 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",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' blob: data: https: wss: ws:",
|
||||
"worker-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'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ');
|
||||
|
||||
/** Response headers shared by the dev and preview servers. */
|
||||
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. */
|
||||
export function createBrowserViteConfig({
|
||||
@@ -10,10 +55,19 @@ export function createBrowserViteConfig({
|
||||
mapillaryToken,
|
||||
host = 'localhost',
|
||||
port = 4173,
|
||||
allowedHosts = DEFAULT_ALLOWED_HOSTS,
|
||||
command,
|
||||
} = {}) {
|
||||
return {
|
||||
plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
|
||||
plugins: [
|
||||
// First, so no provider route answers a Host the server does not allow.
|
||||
hostCheckPlugin(),
|
||||
cesium(),
|
||||
applicationHtmlPlugin(),
|
||||
...plugins,
|
||||
embedFramingPlugin(),
|
||||
panelBuildPlugin(),
|
||||
],
|
||||
...(publicDir === undefined ? {} : { publicDir }),
|
||||
// A production build must not clean the dependency cache a running dev
|
||||
// server is still serving optimized module URLs from.
|
||||
@@ -35,19 +89,22 @@ export function createBrowserViteConfig({
|
||||
server: {
|
||||
host: host || 'localhost',
|
||||
port: parseInt(port, 10) || 4173,
|
||||
allowedHosts:
|
||||
host === '0.0.0.0' || host === '::'
|
||||
? true
|
||||
: ['localhost', '127.0.0.1', '.local'],
|
||||
// A wildcard bind only chooses interfaces; it never disables Vite's
|
||||
// Host-header check. LAN hostnames are added explicitly (see
|
||||
// build/allowedHosts.js); IP addresses are always accepted.
|
||||
allowedHosts: [...allowedHosts],
|
||||
fs: {
|
||||
deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'],
|
||||
},
|
||||
// These headers protect the document containing Provider Settings.
|
||||
headers: {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "frame-ancestors 'none'",
|
||||
},
|
||||
// These headers protect the document containing Provider Settings and
|
||||
// give the whole page a real Content-Security-Policy (BROWSER_CSP).
|
||||
// Embed-mode documents are framable instead: embed-framing.js rewrites
|
||||
// only the frame-ancestors directive and keeps the rest of the policy.
|
||||
headers: BROWSER_HEADERS,
|
||||
},
|
||||
// The preview server serves the same documents, so it carries the same
|
||||
// framing + CSP hardening (one constant, no drift).
|
||||
preview: { headers: BROWSER_HEADERS },
|
||||
define: {
|
||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||
|
||||
Reference in new issue
Block a user