Files
competition-website/src/utils/richtext.js
qubiaobiao 1bfe007149 feat: 综合大题改造(替代 Word 上传)+ MockExam 按组分支
中职/高职组以「综合大题」替代原 Word 文件上传:管理员在后台录入题干
(wangEditor 富文本,含表格),系统按组随机抽题,考生在 3 个小问内作答,
每小题 10 分、大题合计 30 分。

- api/admin.js / api/exam.js / stores/exam.js:大题题库 CRUD、抽题、作答与评分链路
- Competition.vue:Word 上传区块替换为大题作答区(3 小问 + 字数上限 + 富文本题干渲染)
- Admin.vue:大题题库管理、考生作答查看与人工评分
- MockExam.vue:按组分支——中职/高职显示综合大题,其余组保持 Word 答题;侧栏与提交提示同步
- RichEditor.vue + utils/richtext.js:题干富文本渲染前做白名单消毒(零依赖,纵深防御)

说明:big_questions / big_question_answers 建表迁移 023/024 随后端仓库提交。
2026-09-21 11:17:59 +08:00

229 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 富文本渲染安全工具(零依赖白名单消毒)
*
* 背景:综合大题题干由管理员通过 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, '')
}
}
/**
* 判断富文本内容是否为空(含只有空白/空标签/&nbsp; 的情况)
* @param {string} html
* @returns {boolean}
*/
export function isRichTextEmpty(html) {
const text = String(html == null ? '' : html)
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/gi, '')
.replace(/[\s\u3000]/g, '')
return text.length === 0
}