72 lines
2.8 KiB
TypeScript
72 lines
2.8 KiB
TypeScript
/* ============================================================================
|
|||
|
|
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;
|
||
|
|
}
|