File size: 3,062 Bytes
dbb1bf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
declare const safeHtmlBrand: unique symbol;
export interface SafeHtml {
readonly [safeHtmlBrand]: true;
toString(): string;
}
export type SafeHtmlInterpolation = SafeHtml | string | number | boolean | null | undefined;
class SafeHtmlValue implements SafeHtml {
public declare readonly [safeHtmlBrand]: true;
constructor(private readonly html: string) {}
public toString(): string {
return this.html;
}
}
function isSafeHtml(value: unknown): value is SafeHtml {
return value instanceof SafeHtmlValue;
}
export function escapeHtml(str: string): string {
if (!str) return '';
return String(str).replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char] || char);
}
export function safeHtml(
strings: TemplateStringsArray,
...values: SafeHtmlInterpolation[]
): SafeHtml {
let html = strings[0] ?? '';
for (let i = 0; i < values.length; i += 1) {
const value = values[i];
html += isSafeHtml(value) ? value.toString() : escapeHtml(String(value ?? ''));
html += strings[i + 1] ?? '';
}
return new SafeHtmlValue(html);
}
export function unsafeRawHtml(html: string, reason: string): SafeHtml {
if (!reason.trim()) {
throw new Error('unsafeRawHtml() requires an audit reason');
}
return new SafeHtmlValue(String(html));
}
/**
* Joins already-safe HTML fragments. String separators are treated as text and
* escaped; pass safeHtml`<br>` or unsafeRawHtml('<br>', reason) for markup.
*/
export function joinSafeHtml(parts: SafeHtml[], separator: SafeHtml | string = ''): SafeHtml {
const safeSeparator = isSafeHtml(separator) ? separator.toString() : escapeHtml(separator);
return new SafeHtmlValue(parts.map(safeHtmlToString).join(safeSeparator));
}
export function safeHtmlToString(html: SafeHtml): string {
return html.toString();
}
export function sanitizeUrl(url: string): string {
if (!url) return '';
const trimmed = String(url).trim();
if (!trimmed) return '';
const isAllowedProtocol = (protocol: string) => protocol === 'http:' || protocol === 'https:';
try {
const parsed = new URL(trimmed);
if (isAllowedProtocol(parsed.protocol)) {
return escapeAttr(parsed.toString());
}
} catch {
// Not an absolute URL, continue and validate as relative.
}
if (!/^(\/|\.\/|\.\.\/|\?|#)/.test(trimmed)) {
return '';
}
try {
const base = typeof window !== 'undefined' ? window.location.origin : 'https://example.com';
const resolved = new URL(trimmed, base);
if (!isAllowedProtocol(resolved.protocol)) {
return '';
}
return escapeAttr(trimmed);
} catch {
return '';
}
}
export function escapeAttr(str: string): string {
return escapeHtml(str);
}
export function safeUrlAttr(url: string): SafeHtml {
// sanitizeUrl() returns an attribute-escaped URL or an empty string.
return unsafeRawHtml(sanitizeUrl(url), 'URL passed through sanitizeUrl() for HTML attribute interpolation');
}
|