/** * 富文本渲染安全工具(零依赖白名单消毒) * * 背景:综合大题题干由管理员通过 wangEditor 录入富文本(含表格),考生端以 v-html 渲染。 * v-html 直接插入未经处理的 HTML 等价于把渲染权交给内容作者,因此必须做纵深防御: * 服务端已做基础危险片段拦截,前端在渲染前再做一次白名单收敛,保证即使内容被 * 绕过入口写入(历史数据 / 直接改库 / 其他写入通道),渲染侧依然无法执行脚本。 * * 白名单策略(默认拒绝): * - 标签:仅保留排版类标签(段落 / 标题 / 列表 / 表格 / 图片 / 链接 / 行内强调) * - 属性:仅保留各标签的排版必需属性 + colspan/rowspan 等表格结构属性 * - 协议:href/src 仅允许 http / https / mailto / 站内相对路径 / data:image * - style:保留(wangEditor 靠它输出对齐与颜色),但剔除 url()/expression() 等高危片段 * * 未知标签走「解包」而非「整体删除」,避免把内容一起吃掉;危险标签整体丢弃并连同子树。 */ // 整体丢弃(连同子树)——脚本/样式/嵌入对象/表单交互类 const DROP_TAGS = new Set([ 'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'link', 'meta', 'base', 'form', 'input', 'button', 'textarea', 'select', 'option', 'svg', 'math', 'template', 'noscript', 'audio', 'video', 'source', 'track', 'canvas', 'portal', 'dialog', 'marquee', ]) // 允许保留的标签(其余走解包) const ALLOW_TAGS = new Set([ 'p', 'br', 'hr', 'div', 'span', 'section', 'article', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup', 'mark', 'small', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', 'table', 'thead', 'tbody', 'tfoot', 'caption', 'colgroup', 'col', 'tr', 'th', 'td', 'a', 'img', 'font', ]) // 允许保留的属性(全局) const ALLOW_ATTRS_GLOBAL = new Set(['style', 'class', 'title', 'dir', 'lang']) // 标签私有属性白名单 const ALLOW_ATTRS_BY_TAG = { a: new Set(['href', 'target', 'rel']), img: new Set(['src', 'alt', 'width', 'height']), td: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height']), th: new Set(['colspan', 'rowspan', 'align', 'valign', 'width', 'height', 'scope']), col: new Set(['span', 'width']), colgroup: new Set(['span', 'width']), table: new Set(['border', 'cellpadding', 'cellspacing', 'width', 'align']), ol: new Set(['start', 'type']), font: new Set(['color', 'size', 'face']), } // style 属性内的危险片段(命中即整条 style 丢弃,宁可丢排版不丢安全) const DANGEROUS_STYLE = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|\/\*/i // 危险协议(含被空白/控制字符混淆的变体,如 "java\tscript:") const DANGEROUS_PROTOCOL = /^(?:javascript|vbscript|file|blob)\s*:/i /** * 归一化协议文本:去掉 ASCII 控制字符与零宽字符后判协议 * (防止 "java script:" 之类的混淆绕过) * @param {string} raw * @returns {string} */ function normalizeUrl(raw) { return String(raw || '') // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u0020\u007f\u200b-\u200f\ufeff]/g, '') .toLowerCase() } /** * 判断 URL 属性值是否安全 * @param {string} raw - 原始属性值 * @returns {boolean} */ function isSafeUrl(raw) { const value = String(raw || '').trim() if (!value) return false const normalized = normalizeUrl(value) if (DANGEROUS_PROTOCOL.test(normalized)) return false if (normalized.startsWith('data:')) { // 仅允许内联图片,杜绝 data:text/html(可执行脚本) return /^data:image\/(png|jpe?g|gif|webp|bmp|svg\+xml);/i.test(normalized) } // 站内相对路径、锚点、以及 http/https/mailto/tel 放行 return /^(https?:|mailto:|tel:|\/|\.\/|\.\.\/|#)/i.test(normalized) } /** * 递归消毒 DOM 子树 * @param {Node} node */ function sanitizeNode(node) { // 从后往前遍历,删除时不影响未处理节点的索引 const children = Array.from(node.childNodes) for (const child of children) { // 文本节点:保留 if (child.nodeType === Node.TEXT_NODE) continue // 注释 / CDATA / 处理指令:整体丢弃 if (child.nodeType !== Node.ELEMENT_NODE) { child.remove() continue } const tag = child.tagName.toLowerCase() if (DROP_TAGS.has(tag)) { child.remove() continue } if (!ALLOW_TAGS.has(tag)) { // 未知标签:解包(把子节点提到父级),保留其内容但去掉标签语义 sanitizeNode(child) while (child.firstChild) { node.insertBefore(child.firstChild, child) } child.remove() continue } sanitizeElementAttrs(child, tag) sanitizeNode(child) } } /** * 收敛单个元素的属性 * @param {Element} el * @param {string} tag - 小写标签名 */ function sanitizeElementAttrs(el, tag) { const tagAttrs = ALLOW_ATTRS_BY_TAG[tag] || new Set() for (const attr of Array.from(el.attributes)) { const name = attr.name.toLowerCase() const value = attr.value // 所有 on* 事件属性一律移除;data-* 也一律移除(无渲染需要) if (name.startsWith('on') || name.startsWith('data-')) { el.removeAttribute(attr.name) continue } if (!ALLOW_ATTRS_GLOBAL.has(name) && !tagAttrs.has(name)) { el.removeAttribute(attr.name) continue } if (name === 'style') { if (DANGEROUS_STYLE.test(value)) { el.removeAttribute(attr.name) } continue } if (name === 'href' || name === 'src') { if (!isSafeUrl(value)) { el.removeAttribute(attr.name) } continue } if (name === 'target') { // 只允许新窗口,并强制补 rel 防 window.opener 劫持 if (value !== '_blank') el.removeAttribute(attr.name) continue } if (name === 'rel') { continue } // 数值型结构属性:仅允许纯数字 if (['colspan', 'rowspan', 'span', 'start', 'width', 'height', 'border', 'cellpadding', 'cellspacing'].includes(name)) { if (!/^\d+$/.test(String(value).trim())) { el.removeAttribute(attr.name) } } } if (tag === 'a' && el.getAttribute('target') === '_blank') { el.setAttribute('rel', 'noopener noreferrer nofollow') } } /** * 消毒富文本 HTML,返回可直接用于 v-html 的安全字符串 * * @param {string} html - 原始富文本 HTML * @returns {string} 消毒后的 HTML(非法输入返回空串) */ export function sanitizeHtml(html) { const raw = String(html == null ? '' : html) if (!raw.trim()) return '' // 无 DOM 环境(如 SSR/单测)时退化为「剥标签」而不是原样返回,避免降级后反而放开注入面 if (typeof window === 'undefined' || typeof window.DOMParser === 'undefined') { return raw.replace(/<[^>]*>/g, '') } try { // 用