一、概述
本方案针对 在线编辑表格 功能中 "输入 <img src=x οnerrοr=confirm(1)> 即触发弹框" 的安全问
题。该现象属于典型的 存储型跨站脚本漏洞(Stored XSS) :用户提交的内容未经过滤或转义,被浏览器当作 HTML 解析,其中的事件属性(onerror)得以执行。
本文从 问题定位、Vue3 前端修复 、Java 后端防护、CSP 兜底、验证方法五个层面,给出可直接落地的修复方案,并在最后给出实施优先级建议。
二、问题定位:表格里谁在执行 HTML
结论先行:表格在线编辑出现弹框,绝大多数情况是 单元格内容被当作 HTML 插入 DOM 了------要么前端某处使用 v-html (或表格库的 HTML 渲染能力)直接回显单元格,要么后端把原始payload 存库后原样返回。Vue3的 {{ }} 插值本身会自动转义,因此问题一定出在"绕过了转义"的那条路径上。
排查入口(按出现频率排序):
- 前端使用了 `v-html` 渲染单元格 :例如 <span v-html="row.content">,这是最常见的入口。
- 表格库的 HTML 渲染开关被打开:部分表格 / 单元格组件支持 HTML 模式,例如某些 formatter 直接返回 HTML 字符串。
- 后端返回时参与了拼接 :把内容拼进模板,或使用 th:utext 等不转义的方式输出。
判断依据:confirm(1) 能够弹出,说明注入的标签被浏览器当成真实 DOM 解析并执行了事件, 纯文本插值无法做到这一点。因此,关键修复动作就是:让单元格内容永远走"文本"通道,而不是"HTML"通道。
三、Vue3 前端修复方案
3.1 首选:把 v-html 换成插值
能解决就别上库------这是最简单也最彻底的修复方式。
借助 dompurify插件封装清洗方法 safeHtml 统一使用:
TypeScript
import DOMPurify from 'dompurify'
// 白名单:只放行安全标签与属性
const config = {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's',
'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li',
'a', 'span', 'div', 'blockquote', 'code', 'pre',
'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
ALLOWED_ATTR: ['href', 'title', 'target', 'rel', 'class', 'colspan', 'rowspan'],
// 只允许 http/https/ftp/mailto 等协议,拦截 javascript:、data:
ALLOWED_URI_REGEXP: /^(?:(?:https?|ftp|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
}
export function safeHtml(dirty: any) {
return DOMPurify.sanitize(dirty, config)
}
实际项目实践:
记录修复LuckySheet安全测试XSS漏洞问题-CSDN博客
TypeScript
hook: {
// 钩子1. 粘贴前处理(解决Ctrl+V粘贴payload场景)
rangePasteBefore: function (range: any, data: any) {
// 判断:清洗后的字符串和原始字符串不一致===存在非法标签/属性
if (safeHtml(data) !== data) {
ElNotification.warning(`检测到单元格【${range[0].row[0] + 1}:${range[0].column[0] + 1}】包含违规内容,请勿粘贴`)
return false
}
},
// 钩子2. 进入单元格编辑模式之前触发
cellEditBefore: function () {
// 等待DOM渲染出富文本编辑框
setTimeout(() => {
const editInput = document.getElementById('luckysheet-rich-text-editor')
if (!editInput) return
// 防止重复绑定事件
if (editInput._hasXssWatch) return
editInput._hasXssWatch = true
// 实时input输入监听:每敲一个字触发
editInput.addEventListener('input', function (e) {
const originText = this.textContent;
const cleanText = safeHtml(originText)
// 清洗前后不一致,说明存在非法标签,强制替换成安全纯文本
if (cleanText !== originText) {
ElNotification.warning('检测到违规内容,已自动清洗')
this.textContent = cleanText
}
})
}, 50);
},
// 钩子3. 单元格编辑完(兜底一层)
cellUpdateBefore: function (r: number, c: number, data: any) {
if (safeHtml(data) !== data) {
ElNotification.warning(`检测到单元格【${r + 1}:${c + 1}】包含违规内容,已自动清洗`)
return false
}
}
}
四、优化方案版本
上述方法会有一个问题就是,Luckysheet内部无法正常复制粘贴内容了,而且一些聊天界面的图片也无法复制粘贴进来,所以需要再优化一版:
AI 咨询过程:
问题一:现在的测试问题是: 1、已经没办法直接复制粘贴单元格内容了,因为粘贴内容里面会包含标签,然后 luckysheet 复制粘贴里会有一个 colgroup 标签,所以会导致清洗前后不一样!==判断会不通过,导致粘贴不上,虽然只是基础数据,但是普通基础文本是得能够正常粘贴的。 2、图片没有办法粘贴了,因为 img 标签不在白名单内,但是还是得需要能够粘贴正常图片的。 最后我感觉已经不能用:"safeHtml(data)!==data"这个判断逻辑了,我的想法是改成判断粘贴内容是否包含 onerror 或者 onclick 这些能触发弹窗的事件。 然后你帮我综合考虑一下,如何改进更好呢。
❌ 不推荐只靠匹配
onerror/onclick这类事件属性黑名单(绕过方式非常多:onload、onmouseover、javascript:伪协议、svg 注入、formaction、@import、xlink:href等,漏杀风险高,安全测试很容易绕过) ✅ 你的两个痛点:
- Luckysheet 原生复制单元格自带
colgroup等内部结构标签 → 清洗后字符串对不上,直接拦截正常单元格复制- 图片需要放行合法
img,但要禁止img上的危险事件、危险 src- 旧逻辑
safeHtml(data) !== data彻底废掉,不能继续用体改造思路(分两层,兼顾业务 + 安全)
分层策略
- 区分两种粘贴来源【最重要,解决 colgroup 问题】
- ✅ 来源 A:Luckysheet 内部单元格复制(同表格复制) :内容自带
colgroup、tr、td、luckysheet内部标记,直接放行,不走 HTML 清洗 + 对比逻辑- ⚠️ 来源 B:外部粘贴(网页、记事本、word、截图图片、别的地方复制来的 HTML):走 DOMPurify 白名单清洗,图片放行但严控属性
怎么区分来源? Luckysheet 内部复制的 HTML,会带有它专属特征:包含
luckysheet关键字、colgroup+ 行列结构; 外部纯文本 / 外部 html 一般没有这套结构。
DOMPurify 配置改造:放开 img 白名单,但是阉割危险属性
- 允许标签:
img- 禁止 img 上所有事件属性:
onerror,onload,onclick,onmouse*、禁止src="javascript:xxx"- 可选:可以加 src 域名白名单(只允许你平台自己的图片域名,外部图片也可以根据业务开关)
判断逻辑替换方案(替代
safeHtml !== data) 不再对比清洗前后整个字符串相等。改成:清洗之后,是否存在被 DOMPurify 移除掉的危险节点 / 危险属性 DOMPurify 自带钩子,可以拿到「哪些内容被删掉了」; 或者:清洗完之后,对比【原始内容里有没有危险特征】+【清洗后的安全内容用于渲染】 业务表现:
- 外部粘贴正常纯文本:清洗前后无危险内容 → 正常粘贴
- 外部粘贴带
<img src=x onerror=confirm(1)>→ purify 会删掉onerror,保留 img 标签,图片能粘贴,恶意事件被干掉- 外部粘贴 svg、脚本、其他事件标签 → 直接清除危险部分
- 内部 luckysheet 复制(带 colgroup)→ 识别是内部复制,直接放行,不进入 purify 清洗
问题二:
attr 上没有 allowed,有的是 allowedAttributes ,然后复制粘贴了,并没有阻止弹窗,明明 onerror 在 REMOVE_ATTR 名单里,怎么 hasDanger 没有赋值为 true 呢?帮我看一下
问题根源找到了
- 钩子
uponSanitizeAttribute里面,判断属性是否放行的字段是attr.allowedAttributes,不是attr.allowed(你截图里打印也印证了这点)- 只写
REMOVE_ATTR有坑:DOMPurify 的REMOVE_ATTR只是附加移除清单 ,在部分版本里,不会自动把attr.allowed = false;- 最关键:
keepAttr: true代表当前这个属性DOMPurify默认打算保留!!截图里 onerror 这一条:keepAttr: true也就是说:单纯配置 REMOVE_ATTR 在你当前这个 DOMPurify 版本,没有成功把 onerror 标记为禁止属性,钩子没命中,hasDanger 不会变成true,属性保留,弹窗触发。
两种修复方式(推荐用钩子强制拦截事件属性,优先级最高)
原理:在
uponSanitizeAttribute钩子里面,主动强制把所有 on 事件属性拒绝,设置 keepAttr = false*,这个钩子是在属性校验阶段执行,比配置表优先级更高。修正后的 xssCleanHtml 核心代码
TypeScriptfunction xssCleanHtml(html) { let hasDanger = false DOMPurify.addHook('uponSanitizeAttribute', (node, attr) => { // attr.attrName 就是属性名,比如 onerror const attrName = attr.attrName // 匹配所有on开头事件属性,onerror / onload / onclick / onmousexxx if (/^on/i.test(attrName)) { // 强制不移除这个属性不行!必须设置 keepAttr = false,DOMPurify 才会删掉它 attr.keepAttr = false hasDanger = true } // 额外拦截 xlink:href 这类危险svg属性 if (attrName === 'xlink:href') { attr.keepAttr = false hasDanger = true } // 校验src是否是 javascript: 伪协议 if (attrName === 'src') { const val = attr.attrValue?.trim() || '' if (val.toLowerCase().startsWith('javascript:')) { attr.keepAttr = false hasDanger = true } } }) DOMPurify.addHook('uponSanitizeElement', (node, data) => { if (data.allowedTags[data.tagName] === false) { hasDanger = true } }) const purifyConfig = { ADD_TAGS: ['img'], ADD_ATTR: ['src', 'alt', 'width', 'height'], // 这里REMOVE_ATTR可以保留作为兜底,但是不要依赖它做主拦截 REMOVE_ATTR: [ 'onerror','onload','onclick','onmouseover','onmouseout','onmousemove', 'onkeydown','onkeyup','onfocus','onblur','onabort','oncanplay','ontimeupdate','formaction' ], ALLOWED_TAGS: ['b','i','u','span','br','p','img'], ALLOWED_UNKNOWN_PROTOCOLS: [] } const safeHtml = DOMPurify.sanitize(html, purifyConfig) DOMPurify.removeAllHooks() return { safeHtml, hasDanger } }重点解释截图里的字段
TypeScript{ attrName: 'onerror', attrValue: 'confirm(1)', keepAttr: true, // ❗DOMPurify原本打算保留这个属性!这就是元凶 allowedAttributes: {}, }
keepAttr:主控开关,`true=保留该属性,false=删除该属性**allowedAttributes:是当前标签允许属性的集合,不是这个属性是否被允许的布尔标记 之前你的钩子写的是判断attr.allowed,这个字段根本不存在,所以永远进不去标记危险的逻辑,hasDanger 一直是 false
额外补充2个必查坑(还是会弹窗的情况)
坑1:DOMPurify版本差异
低版本 DOMPurify 对
REMOVE_ATTR行为不稳定,不要只靠配置项删事件属性,一定要用 uponSanitizeAttribute 钩子强制置 keepAttr=false,钩子是版本兼容最好的方式。坑2:rangePasteBefore 返回逻辑确认
你必须确认:
TypeScriptconst { safeHtml, hasDanger } = xssCleanHtml(rawContent) pasteData.content = safeHtml // 一定要覆盖粘贴的原始内容 return pasteData如果只是标记了 hasDanger,但是没有把 pasteData.content 替换成 safeHtml,原始恶意内容依然会交给Luckysheet渲染,照样弹窗!
坑3:svg payload 补充(安全测试后续会测)
svg onload=alert(1)这种,上面uponSanitizeElement会捕获,只要不在 ALLOWED_TAGS 放行svg就没问题。
测试方法(直接打印看效果)
粘贴 payload
<img src=x onerror=confirm(1)>,在钩子内打印:
TypeScriptconsole.log('属性信息', attr) console.log('要删除吗', /^on/i.test(attr.attrName))预期:
- 命中正则,
attr.keepAttr = falsehasDanger = true- 返回的 safeHtml 里面的 img 不再带有 onerror 属性
兜底方案(如果改完钩子,luckysheet还是渲染原始内容)
Luckysheet 部分版本的 rangePasteBefore 钩子有bug,修改 pasteData.content 不生效。 这种情况:
- 先CSP头兜底
script-src 'self',浏览器层面阻止事件执行(就算DOM上挂了onerror,也不会执行弹窗)- 叠加编辑框原生paste事件,在原生事件层清洗剪贴板文本,作为前置拦截
基本没啥问题了,不过还需要再优化一下,因为我从外界 Excel 表格里面复制进来粘贴的话(我看是会有一大个 html 里面好多内容),还是没有拦截住,你帮我看一下如何再优化解决一下

这个问题暂时忽略,不做考虑了,后期有需要再做优化,OK,over。
五、最终代码实现
src\utils\safeHtml.ts
TypeScript
import DOMPurify from "dompurify";
// 白名单:只放行安全标签与属性
const config = {
ALLOWED_TAGS: [
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"h1",
"h2",
"h3",
"h4",
"ul",
"ol",
"li",
"a",
"span",
"div",
"blockquote",
"code",
"pre",
"table",
"thead",
"tbody",
"tr",
"td",
"th",
"colgroup", // Luckysheet特有
"img", // 放行img标签
],
// ALLOWED_ATTR: ["href", "title", "target", "rel", "class", "colspan", "rowspan"],
// 只允许 http/https/ftp/mailto 等协议,拦截 javascript:、data:
ALLOWED_URI_REGEXP: /^(?:(?:https?|ftp|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
};
// 新方案二:优化调整(适配图片和正常文本粘贴)
// 自定义配置
const purifyConfig = {
ADD_TAGS: ["img"], // 放行img标签
ADD_ATTR: ["src", "alt", "width", "height"], // img只允许这些安全属性
REMOVE_ATTR: [
// 全部事件属性黑名单
"onerror",
"onload",
"onclick",
"onmouseover",
"onmouseout",
"onmousemove",
"onkeydown",
"onkeyup",
"onfocus",
"onblur",
"onabort",
"oncanplay",
"ontimeupdate",
"formaction",
],
FORBID_ATTR: ["formaction", "xlink:href"],
ALLOWED_TAGS: [
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"h1",
"h2",
"h3",
"h4",
"ul",
"ol",
"li",
"a",
"span",
"div",
"blockquote",
"code",
"pre",
"table",
"thead",
"tbody",
"tr",
"td",
"th",
"img",
],
// 关键:允许office命名空间,但是不允许命名空间上挂危险事件
ADD_NAMESPACES: {
o: "urn:schemas-microsoft-com:office:office",
x: "urn:schemas-microsoft-com:office:excel",
},
};
/**
* 统一清洗函数,适配:普通html、外部网页、Excel大块富文本
* @param {string} html 粘贴原始html
* @returns { safeHtml:string, hasDanger:boolean }
*/
export function xssCleanHtml(html: any) {
let hasDanger = false;
// 钩子1:属性阶段优先拦截(主拦截)
DOMPurify.addHook("uponSanitizeAttribute", (node, attr) => {
// attr.attrName就是属性名,比如onerror
const attrName = attr.attrName?.toLowerCase() || "";
const attrVal = attr.attrValue?.trim() || "";
// 匹配所有on开头事件属性,onerror / onload / onclick / onmouse(全部拦截)
if (/^on/.test(attrName)) {
// 强制不移除这个属性不行!必须设置 keepAttr = false,DOMPurify才会删掉它
attr.keepAttr = false;
hasDanger = true;
return;
}
// 拦截危险协议
if (["src", "href", "formaction"].includes(attrName)) {
if (
attrVal.toLowerCase().startsWith(`javascript:`) ||
attrVal.toLowerCase().startsWith(`vbscript:`)
) {
attr.keepAttr = false;
hasDanger = true;
}
}
// 额外拦截 xlink:href 这类危险svg属性
if (attrName === "xlink:href") {
attr.keepAttr = false;
hasDanger = true;
}
});
// 钩子2:元素过滤,禁止svg/script等
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
const tag = data.tagName?.toLowerCase();
if (["svg", "script", "iframe"].includes(tag)) {
hasDanger = true;
}
});
// 添加钩子3:当purify移除元素/属性时标记存在风险
DOMPurify.addHook("afterSanitizeAttributes", function (node) {
// 检测img的src是javascript协议
if (node.tagName === "IMG") {
const src = node.getAttribute("src") || "";
if (src.startsWith("javascript:")) {
node.removeAttribute("src");
hasDanger = true;
}
}
});
DOMPurify.sanitize(html, purifyConfig);
// const safeHtml = DOMPurify.sanitize(html, purifyConfig)
// 用完移除钩子,防止污染下次调用
DOMPurify.removeAllHooks();
return { hasDanger };
// return { safeHtml, hasDanger };
}
export function safeHtml(dirty: any) {
return DOMPurify.sanitize(dirty, config);
}
src\views\LuckySheet\edit.vue 主要代码部分:

