229 lines
7.8 KiB
JavaScript
229 lines
7.8 KiB
JavaScript
|
|
/**
|
|||
|
|
* 富文本渲染安全工具(零依赖白名单消毒)
|
|||
|
|
*
|
|||
|
|
* 背景:综合大题题干由管理员通过 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 {
|
|||
|
|
// 用 <template> 承载:内容落在 inert 的 DocumentFragment 中,不会执行脚本,
|
|||
|
|
// 也不存在「被内容里的 </div> 提前闭合包装层导致越界/丢内容」的问题。
|
|||
|
|
const tpl = document.createElement('template')
|
|||
|
|
tpl.innerHTML = raw
|
|||
|
|
sanitizeNode(tpl.content)
|
|||
|
|
return tpl.innerHTML
|
|||
|
|
} catch (e) {
|
|||
|
|
// 解析失败一律降级为纯文本,绝不回退到原始 HTML
|
|||
|
|
return raw.replace(/<[^>]*>/g, '')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 判断富文本内容是否为空(含只有空白/空标签/ 的情况)
|
|||
|
|
* @param {string} html
|
|||
|
|
* @returns {boolean}
|
|||
|
|
*/
|
|||
|
|
export function isRichTextEmpty(html) {
|
|||
|
|
const text = String(html == null ? '' : html)
|
|||
|
|
.replace(/<[^>]*>/g, '')
|
|||
|
|
.replace(/ /gi, '')
|
|||
|
|
.replace(/[\s\u3000]/g, '')
|
|||
|
|
return text.length === 0
|
|||
|
|
}
|