fix(security): replace regex HTML sanitizer with DOM-based sanitization

cx
This commit is contained in:
vicrodh committed 2026-03-01 09:20:21 -06:00
1 parent ead36545d3
commit 426d0ea3ff
1 file changed
+103 -20
+103 -20
View File
@@ -4,34 +4,117 @@
*/
const ALLOWED_TAGS = new Set([
'b', 'i', 'em', 'strong', 'br', 'a', 'p', 'span', 'ul', 'ol', 'li',
'b', 'i', 'em', 'strong', 'br', 'a', 'p', 'span', 'ul', 'ol', 'li'
]);
/** Strip event handler attributes and javascript: URLs */
function stripDangerousAttrs(tag: string): string {
// Remove on* event attributes
tag = tag.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
// Remove javascript: URLs from href/src
tag = tag.replace(/(href|src)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '$1=""');
return tag;
const ALLOWED_ANCHOR_ATTRS = new Set(['href', 'title', 'target', 'rel']);
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
function escapeHtml(input: string): string {
return input
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function isSafeHref(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.startsWith('/') || trimmed.startsWith('#')) return true;
try {
const parsed = new URL(trimmed, 'https://qbz.local');
return SAFE_PROTOCOLS.has(parsed.protocol);
} catch {
return false;
}
}
function sanitizeElement(element: Element): void {
const children = Array.from(element.childNodes);
for (const child of children) {
if (child.nodeType === Node.COMMENT_NODE) {
child.parentNode?.removeChild(child);
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const childElement = child as Element;
const tagName = childElement.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tagName)) {
const replacementNodes = Array.from(childElement.childNodes);
for (const replacementNode of replacementNodes) {
childElement.parentNode?.insertBefore(replacementNode, childElement);
if (replacementNode.nodeType === Node.ELEMENT_NODE) {
sanitizeElement(replacementNode as Element);
}
}
childElement.parentNode?.removeChild(childElement);
continue;
}
const attributes = Array.from(childElement.attributes);
for (const attribute of attributes) {
const attrName = attribute.name.toLowerCase();
// Remove inline handlers and styles from all tags.
if (attrName.startsWith('on') || attrName === 'style') {
childElement.removeAttribute(attribute.name);
continue;
}
if (tagName === 'a') {
if (!ALLOWED_ANCHOR_ATTRS.has(attrName)) {
childElement.removeAttribute(attribute.name);
continue;
}
if (attrName === 'href' && !isSafeHref(attribute.value)) {
childElement.removeAttribute(attribute.name);
}
} else {
// Non-anchor tags keep no attributes.
childElement.removeAttribute(attribute.name);
}
}
if (tagName === 'a') {
const target = childElement.getAttribute('target');
if (target && target !== '_blank' && target !== '_self') {
childElement.removeAttribute('target');
}
if (childElement.getAttribute('target') === '_blank') {
childElement.setAttribute('rel', 'noopener noreferrer nofollow');
}
}
sanitizeElement(childElement);
}
}
export function sanitizeHtml(html: string): string {
if (!html) return '';
// Remove <script> and <iframe> blocks entirely (with content)
let result = html.replace(/<script[\s\S]*?<\/script>/gi, '');
result = result.replace(/<iframe[\s\S]*?<\/iframe>/gi, '');
result = result.replace(/<style[\s\S]*?<\/style>/gi, '');
if (typeof window === 'undefined' || typeof DOMParser === 'undefined') {
return escapeHtml(html);
}
// Process remaining tags: keep allowed, strip others
result = result.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tagName) => {
const lower = tagName.toLowerCase();
if (ALLOWED_TAGS.has(lower)) {
return stripDangerousAttrs(match);
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${html}</div>`, 'text/html');
const container = doc.body.firstElementChild;
if (!container) {
return '';
});
}
return result;
sanitizeElement(container);
return container.innerHTML;
}