import DOMPurify from 'dompurify'; import { marked } from 'marked'; const ALLOWED_TAGS = [ 'a', 'blockquote', 'br', 'code', 'del', 'em', 'h1', 'h2', 'h3', 'h4', 'hr', 'li', 'ol', 'p', 'pre', 'strong', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', ]; export function renderSafeMarkdown(markdown: string, doc: Document = document): string { const parsed = marked.parse(markdown, { async: false, breaks: true, gfm: true, }); const view = doc.defaultView; const purifier = view ? DOMPurify(view) : DOMPurify; const sanitized = purifier.sanitize(parsed, { ALLOWED_TAGS, ALLOWED_ATTR: ['href', 'title'], ALLOW_DATA_ATTR: false, }); const template = doc.createElement('template'); template.innerHTML = sanitized; for (const anchor of template.content.querySelectorAll('a')) { const href = anchor.getAttribute('href') ?? ''; if (!isSafeHref(href)) { anchor.removeAttribute('href'); continue; } if (isExternalHref(href)) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener noreferrer'); } } return template.innerHTML; } function isSafeHref(href: string): boolean { const normalized = href.trim().toLowerCase(); return normalized.startsWith('http://') || normalized.startsWith('https://') || normalized.startsWith('mailto:') || normalized.startsWith('/') || normalized.startsWith('#'); } function isExternalHref(href: string): boolean { return /^https?:\/\//i.test(href); }