/** Markdown 渲染:marked 解析 + DOM 级净化(防 XSS)+ 标题锚点 + 目录提取 */ import { marked } from 'marked' export interface TocItem { id: string text: string level: number } export interface RenderResult { html: string toc: TocItem[] } function slugify(text: string, used: Set): string { const base = text .toLowerCase() .replace(/[^\p{L}\p{N}\s-]/gu, '') .trim() .replace(/\s+/g, '-') || 'section' let slug = base let i = 2 while (used.has(slug)) slug = `${base}-${i++}` used.add(slug) return slug } // L7 加固:补齐 foreign-content/嵌入类标签与危险属性清单(原仅黑名单 9 类 + // on* + javascript:,未覆盖 srcdoc/formaction/xlink:href/srcset/data: URL 等) const BLOCKED_TAGS = new Set([ 'script', 'style', 'iframe', 'object', 'embed', 'form', 'link', 'meta', 'base', 'svg', 'math', 'frame', 'frameset', 'applet', 'template', 'noscript', 'title', ]) /** 危险属性:事件类、URL 注入类与沙箱逃逸类 */ const BLOCKED_ATTRS = new Set([ 'srcdoc', 'sandbox', 'formaction', 'action', 'xlink:href', 'srcset', 'poster', 'background', 'dynsrc', 'lowsrc', 'data', ]) /** 仅允许 http(s)、站内相对路径、锚点与 mailto(src 另允许 data:image/) */ function safeUrl(value: string, isSrc: boolean): boolean { const v = value.trim() if (isSrc && /^data:image\//i.test(v)) return true return /^(https?:|mailto:|\/|#|\.\/)/i.test(v) || !/^[a-z][a-z0-9+.-]*:/i.test(v) } /** 净化 HTML:移除危险标签/危险属性/非白名单协议 URL */ export function sanitizeHtml(html: string): string { const doc = new DOMParser().parseFromString(html, 'text/html') for (const el of [...doc.body.querySelectorAll('*')]) { const tag = el.tagName.toLowerCase() if (BLOCKED_TAGS.has(tag)) { el.remove() continue } for (const attr of [...el.attributes]) { const name = attr.name.toLowerCase() if (name.startsWith('on') || BLOCKED_ATTRS.has(name)) { el.removeAttribute(attr.name) continue } if ((name === 'href' || name === 'src' || name.endsWith(':src') || name.endsWith(':href')) && !safeUrl(attr.value, name === 'src')) { el.removeAttribute(attr.name) } } } return doc.body.innerHTML } /** 渲染 markdown → 净化后的 HTML + 标题目录(h2/h3 作为二级/三级目录) */ export function renderMarkdown(md: string): RenderResult { const rawHtml = marked.parse(md, { gfm: true, breaks: false }) as string const doc = new DOMParser().parseFromString(rawHtml, 'text/html') // 外链新窗口打开;站内锚点保持 for (const a of [...doc.body.querySelectorAll('a[href]')]) { const href = a.getAttribute('href') ?? '' if (/^https?:\/\//i.test(href)) { a.setAttribute('target', '_blank') a.setAttribute('rel', 'noopener noreferrer') } } const used = new Set() const toc: TocItem[] = [] for (const h of [...doc.body.querySelectorAll('h1, h2, h3')]) { const level = Number(h.tagName.substring(1)) const text = (h.textContent ?? '').trim() if (!text) continue const id = slugify(text, used) h.setAttribute('id', id) if (level >= 2) toc.push({ id, text, level }) } return { html: sanitizeHtml(doc.body.innerHTML), toc } }