/** * URL-scheme safety for attributes the browser will navigate or fetch. * * Dependency-free on purpose: the server renderer and the browser bridge both need this, and * the bridge is bundled for the browser. */ /** * Attributes whose value the browser treats as a URL, and will therefore *execute* if the * scheme says to. `href` and `src` are the obvious ones; `formaction` overrides a form's * target from a button, and `xlink:href` is the SVG spelling that filters routinely miss. * * @internal */ export const URL_ATTRIBUTES: ReadonlySet = new Set([ "href", "src", "action", "formaction", "xlink:href", "poster", "data", "cite", "background", "ping", "srcset", ]); /** Schemes that execute script rather than fetch a resource. */ const DANGEROUS_SCHEMES = ["javascript:", "vbscript:", "data:text/html", "data:image/svg"]; /** * Whether a URL is safe to put in a navigable attribute. * * Relative URLs, fragments, query-only URLs and the ordinary network schemes are safe. * `javascript:` and `vbscript:` execute on click; `data:text/html` and `data:image/svg+xml` * execute in the document's own origin when navigated to. Everything is checked after * stripping the whitespace and control characters browsers ignore while parsing a scheme — * `java\tscript:alert(1)` and ` JaVaScRiPt:alert(1)` both navigate. * * @param url - The candidate URL. * @returns `true` when the browser will treat it as a resource reference rather than code. * * @example * isSafeUrl("/dashboard") // true * isSafeUrl("https://example.com") // true * isSafeUrl("java\tscript:alert(1)") // false * * @internal */ export function isSafeUrl(url: string): boolean { // Browsers strip ASCII whitespace and C0 controls before matching the scheme, so the // comparison has to happen on the same string the browser will see. const normalised = url // eslint-disable-next-line no-control-regex -- matching exactly what the parser ignores .replace(/[-\s]/g, "") .toLowerCase(); return !DANGEROUS_SCHEMES.some((scheme) => normalised.startsWith(scheme)); } /** * Value substituted for a URL that would execute. `about:blank` is inert, still a valid URL, * and visibly wrong in a way a developer notices — unlike an empty `href`, which re-navigates * to the current page and reads as a working link. * * @internal */ export const BLOCKED_URL = "about:blank"; /** * Return `url` when it is safe, or {@link BLOCKED_URL} when it would execute. * * Applied to every URL-bearing attribute the renderer emits and every one the bridge writes * from server-pushed state, so `` cannot become one-click XSS * because a user typed a scheme into a profile field. * * @param url - The candidate URL. * @returns The original URL, or {@link BLOCKED_URL}. * * @internal */ export function sanitizeUrl(url: string): string { return isSafeUrl(url) ? url : BLOCKED_URL; }