/* ============================================================================ SAFE HREF ---------------------------------------------------------------------------- Every URL the site renders into an `href` comes out of Strapi, which means it is typed by an editor account rather than by this codebase. An `href` is NOT inert: `javascript:...`, `data:text/html,...` and `vbscript:...` all execute on click, so a single compromised or careless editor account is an XSS vector unless the scheme is checked here, at the point of rendering. `safeHref` is that check, and it is the ONLY thing allowed to decide whether a CMS-supplied URL becomes a link. It returns the cleaned URL when it is safe to put in an `href`, and `undefined` when it is not - and `undefined` must be rendered as plain text, never as an `` with a missing or empty `href`. WHAT PASSES - a fragment (`#section`) or a relative/root-relative path (`/en/about`, `about`, `?page=2`) - no scheme, so nothing can be smuggled - an absolute URL whose protocol is http, https, mailto or tel WHAT IS STRIPPED FIRST Browsers ignore control characters and whitespace inside a scheme, so a newline- or tab-split `javascript:alert(1)` navigates exactly like `javascript:alert(1)`. Those characters are normalised away BEFORE the scheme is read, so this check sees what the browser will see. ========================================================================= */ /** * Characters a browser drops (or treats as insignificant) while parsing a URL: * C0/C1 controls, tab and newlines, plus the zero-width and bidi marks that can * be pasted invisibly into a CMS field. */ const IGNORED_CHARS = /[\u0000-\u001F\u007F-\u009F\u200B-\u200F\u2028\u2029\u202A-\u202E\uFEFF]/g; /** A leading `scheme:`, per RFC 3986. */ const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; /** The only protocols an editor-supplied absolute URL may use. */ const ALLOWED_PROTOCOLS: ReadonlySet = new Set([ "http:", "https:", "mailto:", "tel:", ]); /** * The URL to render, or `undefined` when the value must not become a link. * * @param url anything a CMS field might hold - unknown on purpose, because a * JSON body is not typed until it has been checked. */ export function safeHref(url: unknown): string | undefined { if (typeof url !== "string") return undefined; const value = url.replace(IGNORED_CHARS, "").trim(); if (value === "") return undefined; // No scheme means a fragment, a query or a path: there is nothing to execute. if (!HAS_SCHEME.test(value)) return value; let parsed: URL; try { parsed = new URL(value); } catch { // A scheme that does not parse is not a URL anyone should be sent to. return undefined; } return ALLOWED_PROTOCOLS.has(parsed.protocol) ? value : undefined; }