Vue关于前端XSS漏洞修复方案
一、概述
本方案针对在线编辑表格功能中 “输入<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 统一使用:
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博客
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 核心代码
function 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 } }重点解释截图里的字段
{ 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 返回逻辑确认
你必须确认:
const { 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)>,在钩子内打印:console.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
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 主要代码部分: