// 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