import { DEFAULT_LIMITS } from "./limits.js"; export type SafeUrl = { readonly href: string; readonly host: string }; const ABSOLUTE_HTTP = /^https?:\/\/[^/\\]/i; /** * Accepts only absolute, credential-free http(s) URLs. Everything else — * `javascript:`, `data:`, `vbscript:`, `file:`, protocol-relative, relative, * backslash tricks, embedded controls/whitespace — returns null so the caller * renders the text inertly. */ export function parseSafeHttpUrl(raw: unknown, maxLength = DEFAULT_LIMITS.maxUrlLength): SafeUrl | null { if (typeof raw !== "string") return null; const input = raw.trim(); if (input.length === 0 || input.length > maxLength) return null; // Browsers strip tab/newline inside URLs ("java\tscript:"); refuse instead of guessing. if (/[\u0000-\u0020\u007F-\u00A0\u1680\u2000-\u200F\u2028-\u202F\u205F-\u206F\u3000\uFEFF]/.test(input)) return null; if (!ABSOLUTE_HTTP.test(input)) return null; let url: URL; try { url = new URL(input); } catch { return null; } if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (url.username !== "" || url.password !== "") return null; if (url.hostname === "") return null; if (url.href.length > maxLength) return null; // `URL` serialises IDN hosts as punycode (xn--…), which exposes homoglyph domains. return { href: url.href, host: url.host }; } export type MediaProxyPolicy = { /** Same-origin path of the app's media proxy, e.g. `/api/media-proxy`. */ readonly proxyPath: string; }; export const DEFAULT_MEDIA_PROXY: MediaProxyPolicy = { proxyPath: "/api/media-proxy" }; function assertProxyPath(path: string): void { if (!/^\/[A-Za-z0-9._~\-/]*$/.test(path) || path.startsWith("//") || path.includes("/../") || path.endsWith("/..")) { throw new Error(`safe-content: media proxy path must be a same-origin absolute path, got ${JSON.stringify(path)}`); } } /** Rewrites a remote media URL into a media-proxy URL; null if the origin URL is unsafe. */ export function toProxiedMediaUrl(raw: unknown, policy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): string | null { assertProxyPath(policy.proxyPath); const safe = parseSafeHttpUrl(raw); if (!safe) return null; return `${policy.proxyPath}?url=${encodeURIComponent(safe.href)}`; } /** True when `src` is exactly a media-proxy URL produced by `toProxiedMediaUrl`. */ export function isProxiedMediaUrl(src: unknown, policy: MediaProxyPolicy = DEFAULT_MEDIA_PROXY): src is string { if (typeof src !== "string") return false; assertProxyPath(policy.proxyPath); const prefix = `${policy.proxyPath}?url=`; if (!src.startsWith(prefix)) return false; let decoded: string; try { decoded = decodeURIComponent(src.slice(prefix.length)); } catch { return false; } // Canonical form only: re-deriving the proxy URL must reproduce `src` exactly. return toProxiedMediaUrl(decoded, policy) === src; }