const ALLOWED_TAGS = new Set([ "a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "span", "strong", "table", "tbody", "td", "th", "thead", "tr", "ul", ]); const DROP_CONTENT_TAGS = new Set(["script", "style", "iframe", "object", "embed", "link", "meta"]); const GLOBAL_ALLOWED_ATTRS = new Set(["class"]); const TAG_ALLOWED_ATTRS: Record> = { a: new Set(["href", "title", "target", "rel"]), code: new Set(["class"]), span: new Set(["class"]), }; function isSafeUrl(value: string): boolean { const normalized = value.trim().toLowerCase(); if (!normalized) return false; if ( normalized.startsWith("http://") || normalized.startsWith("https://") || normalized.startsWith("mailto:") || normalized.startsWith("tel:") || normalized.startsWith("/") || normalized.startsWith("#") ) { return true; } return false; } function sanitizeAttributes(element: Element): void { const tagName = element.tagName.toLowerCase(); const allowedForTag = TAG_ALLOWED_ATTRS[tagName] || new Set(); const attrs = Array.from(element.attributes); for (const attr of attrs) { const name = attr.name.toLowerCase(); const value = attr.value; const allowed = GLOBAL_ALLOWED_ATTRS.has(name) || allowedForTag.has(name); if (!allowed) { element.removeAttribute(attr.name); continue; } if ((name === "href" || name === "src") && !isSafeUrl(value)) { element.removeAttribute(attr.name); } } if (tagName === "a") { if (element.getAttribute("target") === "_blank") { element.setAttribute("rel", "noopener noreferrer nofollow"); } else { element.removeAttribute("target"); } } } function sanitizeNode(node: Node): void { if (node.nodeType === Node.TEXT_NODE) return; if (node.nodeType !== Node.ELEMENT_NODE) { node.parentNode?.removeChild(node); return; } const element = node as Element; const tagName = element.tagName.toLowerCase(); if (!ALLOWED_TAGS.has(tagName)) { if (DROP_CONTENT_TAGS.has(tagName)) { element.remove(); return; } const parent = element.parentNode; if (!parent) return; while (element.firstChild) { parent.insertBefore(element.firstChild, element); } parent.removeChild(element); return; } sanitizeAttributes(element); const children = Array.from(element.childNodes); for (const child of children) { sanitizeNode(child); } } export function sanitizeHtml(input: string): string { if (typeof window === "undefined") return input; const parser = new DOMParser(); const doc = parser.parseFromString(input, "text/html"); const nodes = Array.from(doc.body.childNodes); for (const node of nodes) { sanitizeNode(node); } return doc.body.innerHTML; }