initial commit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user