Files

72 lines
2.8 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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 `<a>` 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 `java<LF>script: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<string> = 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;
}