/** * URL sanitization for values placed into href/src attributes. * * Browsers strip ASCII whitespace and a range of control characters from a URL * before resolving its scheme, so "java\nscript:" and "data:\ttext/html" still * execute. Markdown paste decodes entities like " " into those literal * characters before the URL reaches us. We therefore strip the ignored * characters first, then allowlist known-safe schemes - denylists repeatedly * lose to scheme smuggling, allowlists do not. */ /** * Characters a browser ignores when resolving a URL scheme: C0 controls and * space (- ), NBSP, line/paragraph separators, and BOM/ZWNBSP. */ const IGNORED_URL_CHARS = /[\u0000-\u0020\u00a0\u2028\u2029\ufeff]/g; /** Schemes safe to navigate to from an anchor href. */ const SAFE_HREF_SCHEME = /^(?:https?|mailto|tel|sms):/i; /** * data: image subtypes that cannot carry script. Excludes svg+xml and any * "+xml" subtype, which execute embedded script when loaded as a document. */ const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)[;,]/i; /** * Remove the characters a browser ignores when resolving a URL scheme, so * scheme checks see the URL the way the browser will ("java\nscript:" → * "javascript:"). Single source of truth for the ignored-character set. */ export function stripIgnoredUrlChars(url: string): string { return url.replace(IGNORED_URL_CHARS, ''); } /** * True for data: URLs whose subtype is a script-free raster image * (already normalized via {@link stripIgnoredUrlChars}). */ export function isSafeRasterImageDataUrl(url: string): boolean { return SAFE_IMAGE_DATA.test(url); } /** * Return the explicit scheme of a URL (e.g. "javascript:"), or null when the * URL is relative, an anchor, or protocol-relative ("//host"). */ function explicitScheme(url: string): string | null { const match = /^[a-z][a-z0-9+.-]*:/i.exec(url); return match ? match[0] : null; } /** * The scheme a URL declares, as a browser resolves it ("javascript:"), or null * when it declares none — relative, anchor, and protocol-relative URLs. Reads * through {@link stripIgnoredUrlChars}, so "java\nscript:" answers * "javascript:", which is what the sanitizer refused. */ export function urlScheme(url: string): string | null { return explicitScheme(stripIgnoredUrlChars(url)); } /** * Sanitize a URL for use as an anchor href. * Returns the original URL when safe, or null when it must be dropped. * Relative, anchor, and protocol-relative URLs have no scheme to abuse and pass. */ export function safeHref(url: string): string | null { const stripped = stripIgnoredUrlChars(url); if (explicitScheme(stripped) === null) { return url; } return SAFE_HREF_SCHEME.test(stripped) ? url : null; } /** * Sanitize a URL for use as an src. * Allows http(s), relative URLs, and raster data: images. Rejects svg/xml and * html data: URLs (which can execute script) and all script-capable schemes. */ export function safeImageSrc(url: string): string | null { const stripped = stripIgnoredUrlChars(url); if (explicitScheme(stripped) === null) { return url; } if (/^https?:/i.test(stripped) || SAFE_IMAGE_DATA.test(stripped)) { return url; } return null; } /** Schemes a stored media URL may use when it becomes a download anchor's href. */ const SAFE_DOWNLOAD_PROTOCOLS = new Set(['http:', 'https:', 'blob:']); /** * Sanitize a stored media URL for use as a download/open anchor href. * Returns the resolved URL when safe, or null when the action must be refused. * * Stored block data is untrusted — a persisted `javascript:` URL on an anchor * executes in the host page's origin the moment the anchor is clicked, with the * victim's session (this shipped as stored XSS in the audio/video "Download" * action). Relative URLs resolve against the document base; `blob:` is allowed * because the default uploader hands out object URLs for local files, and inert * raster `data:` images are allowed because cover art and pasted images use * them. Everything else — `javascript:`, `data:text/html`, `data:image/svg+xml`, * `vbscript:`, `file:` — is refused. * @param url - stored URL to gate */ export function safeDownloadHref(url: string): string | null { if (url.trim() === '') { return null; } try { const parsed = new URL(url, typeof document !== 'undefined' ? document.baseURI : undefined); if (parsed.protocol === 'data:') { return isSafeRasterImageDataUrl(parsed.href) ? parsed.href : null; } return SAFE_DOWNLOAD_PROTOCOLS.has(parsed.protocol) ? parsed.href : null; } catch { return null; } } /** * True when the URL carries an explicit scheme that is not in the href * allowlist. Used to reject user-entered link targets before insertion. */ export function hasUnsafeScheme(url: string): boolean { const stripped = stripIgnoredUrlChars(url); return explicitScheme(stripped) !== null && !SAFE_HREF_SCHEME.test(stripped); }