feat(builder): add shared escape/safeUrl util
Adds craft/src/utils/escape.ts as the single exported escaping/URL-safety util (escapeHtml, escapeAttr, safeUrl) for later hardening tasks to consolidate the 27 divergent local copies into. html-export.ts now imports escapeHtml from it instead of keeping a private copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared HTML-escaping and URL-safety utilities.
|
||||
*
|
||||
* `escapeHtml` / `escapeAttr` are the single source of truth for escaping
|
||||
* user-supplied strings before they are interpolated into exported/rendered
|
||||
* HTML. `safeUrl` blocks dangerous URL schemes (javascript:, vbscript:,
|
||||
* data:text/html) including whitespace/case/entity-obfuscated variants.
|
||||
*/
|
||||
|
||||
/** Escapes &, <, >, " for safe use in text content and double-quoted attributes. */
|
||||
export function escapeHtml(s: string): string {
|
||||
if (typeof s !== 'string') {
|
||||
if (s === null || s === undefined) return '';
|
||||
s = String(s);
|
||||
}
|
||||
// Ampersand MUST be escaped first, otherwise the entities produced by the
|
||||
// other replacements would themselves be re-escaped (double-escaping).
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Escapes everything escapeHtml does, plus ' and ` for use in any attribute context. */
|
||||
export function escapeAttr(s: string): string {
|
||||
if (typeof s !== 'string') {
|
||||
if (s === null || s === undefined) return '';
|
||||
s = String(s);
|
||||
}
|
||||
return escapeHtml(s)
|
||||
.replace(/'/g, ''')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
// Dangerous scheme prefixes, checked against a normalized copy with all
|
||||
// colons removed (see safeUrl) so that colon-splicing obfuscation like
|
||||
// "java:script:alert(1)" can't slip past a literal "javascript:" check.
|
||||
const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html'];
|
||||
|
||||
/** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */
|
||||
function decodeNumericEntities(s: string): string {
|
||||
return s.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/�*([0-9]+);?/g, (_m, dec) => String.fromCharCode(parseInt(dec, 10)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the trimmed input if it is a safe URL, otherwise returns ''.
|
||||
* Blocks javascript:, vbscript:, and data:text/html schemes, including
|
||||
* whitespace / control-char / case / HTML-entity obfuscated variants.
|
||||
* Allowed URLs are returned UNCHANGED (trimmed only) -- query strings etc.
|
||||
* are never mangled.
|
||||
*/
|
||||
export function safeUrl(s: string): string {
|
||||
if (typeof s !== 'string') return '';
|
||||
|
||||
const trimmed = s.trim();
|
||||
if (trimmed === '') return '';
|
||||
|
||||
// Build a normalized copy for scheme detection only: decode numeric HTML
|
||||
// entities (catches e.g. j -> 'j'), strip whitespace/control chars,
|
||||
// and lowercase.
|
||||
const normalized = decodeNumericEntities(trimmed)
|
||||
.replace(/[\x00-\x20]+/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
// Strip any colons before matching the scheme prefix. This defeats
|
||||
// obfuscation that splices extra colons into the scheme name itself
|
||||
// (e.g. decoding : mid-word produces "java:script:alert(1)", which
|
||||
// would otherwise dodge a literal "^javascript:" check) while still
|
||||
// reliably catching the real "javascript:"/"vbscript:"/"data:text/html"
|
||||
// prefixes once their own colon is removed.
|
||||
const collapsed = normalized.replace(/:/g, '');
|
||||
|
||||
if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
Reference in New Issue
Block a user