// CSP whitelist applied to HTML files previewed in the Files // explorer iframe. We ship a narrow list of trusted CDNs that the // LLM commonly pulls from (Chart.js, D3, Tailwind, etc. via // jsdelivr / unpkg / cdnjs) plus Google Fonts. Anything else — // random `https://` origins, phone-home `fetch()` calls, etc. — // is rejected. // // The list itself lives in `@mulmoclaude/core/remote-view` // (SANDBOXED_VIEW_CDN_ALLOWLIST) so the remote-view CSP and these // desktop policies can't drift — widen it THERE, and keep it // audited: every entry is a potential supply-chain surface. import { SANDBOXED_VIEW_CDN_ALLOWLIST } from "@mulmoclaude/core/remote-view"; import { isRecord } from "../types"; export const HTML_PREVIEW_CSP_ALLOWED_CDNS: readonly string[] = SANDBOXED_VIEW_CDN_ALLOWLIST; // Directives a user may extend from `config/csp.json` (see #1989). Hosts here // are ADDED to the hardcoded base policy, never replace it. `connect-src` is // included but is the sharpest edge — widening it opens a two-way exfiltration // channel for a custom view's scoped token/data (warned about elsewhere). export const CSP_EXTENDABLE_DIRECTIVES = ["frame-src", "script-src", "style-src", "font-src", "img-src", "media-src", "connect-src"] as const; export type CspDirective = (typeof CSP_EXTENDABLE_DIRECTIVES)[number]; export type CspExtraHosts = Partial>; const extraFor = (extra: CspExtraHosts, directive: CspDirective): string[] => [...(extra[directive] ?? [])]; // A user-supplied host is accepted only as a plain https origin (scheme + // host, optional port) — no paths, no wildcards, and none of the `'unsafe-*'` // / `data:` / `blob:` keyword tokens that would blow the policy wide open. // Config widening stays "trust this specific host" and nothing more. Parsed // with `URL` (no hand-rolled regex → no ReDoS): the origin round-trip rejects // paths / query / hash / credentials, and the per-char host check rejects // wildcards like `*.evil.com` that `URL` would otherwise accept as a hostname. function isPlainHttpsHost(value: string): boolean { try { const url = new URL(value); if (url.protocol !== "https:" || value.toLowerCase() !== `https://${url.host}`) return false; return url.hostname.length > 0 && [...url.hostname].every((char) => /[a-z0-9.-]/i.test(char)); } catch { return false; } } export function sanitizeCspExtra(raw: unknown): CspExtraHosts { if (!isRecord(raw)) return {}; const out: CspExtraHosts = {}; for (const directive of CSP_EXTENDABLE_DIRECTIVES) { const value = raw[directive]; if (!Array.isArray(value)) continue; const hosts = value.map((host) => (typeof host === "string" ? host.trim() : "")).filter(isPlainHttpsHost); if (hosts.length > 0) out[directive] = [...new Set(hosts)]; } return out; } /** * Build the CSP string. Split from the wrapper so tests can exercise * the policy without HTML-template noise. * * `origin`, when provided, replaces `'self'` in `img-src`. The preview * iframe is `sandbox="allow-scripts"` only, so its document has an * opaque origin: Safari/WebKit matches `'self'` against the (opaque) * origin tuple and rejects every same-origin image request. Chrome * matches `'self'` against the document URL and works either way. Pass * the explicit server origin from HTTP-header callers; leave it * undefined for the `srcdoc` fallback (where `'self'` is meaningless * either way and there are no same-origin refs to resolve). */ function buildCsp(connectSrc: string, imgSelf: string, cdns: readonly string[], extraImgSrc = "", mediaSrc = "", extra: CspExtraHosts = {}): string { const cdnArr = [...cdns]; // Sanitize at the builder boundary — NOT only at the callers — so an added // host can never carry a `;` (inject another directive) or a `"` (break the // `` attribute), regardless of whether the caller // remembered to sanitize. Idempotent for already-clean input. const safeExtra = sanitizeCspExtra(extra); const directives: string[] = [ "default-src 'none'", // LLM-authored HTML almost always uses inline