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:
228
src/utils/richtext.js
Normal file
228
src/utils/richtext.js
Normal 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, '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断富文本内容是否为空(含只有空白/空标签/ 的情况)
|
||||
* @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
|
||||
}
|
||||
Reference in New Issue
Block a user