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 随后端仓库提交。
This commit is contained in:
2026-09-21 11:17:59 +08:00
parent e8f0195b61
commit 1bfe007149
8 changed files with 1729 additions and 470 deletions

228
src/utils/richtext.js Normal file
View File

@@ -0,0 +1,228 @@
/**
* 富文本渲染安全工具(零依赖白名单消毒)
*
* 背景:综合大题题干由管理员通过 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
}