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 统一使用:

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 等,漏杀风险高,安全测试很容易绕过) ✅ 你的两个痛点:

  1. Luckysheet 原生复制单元格自带 colgroup 等内部结构标签 → 清洗后字符串对不上,直接拦截正常单元格复制
  2. 图片需要放行合法 img,但要禁止 img 上的危险事件、危险 src
  3. 旧逻辑 safeHtml(data) !== data 彻底废掉,不能继续用

体改造思路(分两层,兼顾业务 + 安全)

分层策略

  1. 区分两种粘贴来源【最重要,解决 colgroup 问题】
    • ✅ 来源 A:Luckysheet 内部单元格复制(同表格复制) :内容自带 colgroup、tr、td、luckysheet内部标记,直接放行,不走 HTML 清洗 + 对比逻辑
    • ⚠️ 来源 B:外部粘贴(网页、记事本、word、截图图片、别的地方复制来的 HTML):走 DOMPurify 白名单清洗,图片放行但严控属性

怎么区分来源? Luckysheet 内部复制的 HTML,会带有它专属特征:包含 luckysheet 关键字、colgroup + 行列结构; 外部纯文本 / 外部 html 一般没有这套结构。

  1. DOMPurify 配置改造:放开 img 白名单,但是阉割危险属性

    • 允许标签:img
    • 禁止 img 上所有事件属性:onerror,onload,onclick,onmouse*、禁止 src="javascript:xxx"
    • 可选:可以加 src 域名白名单(只允许你平台自己的图片域名,外部图片也可以根据业务开关)
  2. 判断逻辑替换方案(替代 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 呢?帮我看一下

问题根源找到了

  1. 钩子 uponSanitizeAttribute 里面,判断属性是否放行的字段是 attr.allowedAttributes,不是 attr.allowed(你截图里打印也印证了这点)
  2. 只写 REMOVE_ATTR 有坑:DOMPurify 的 REMOVE_ATTR 只是附加移除清单 ,在部分版本里,不会自动把 attr.allowed = false;
  3. 最关键:keepAttr: true 代表当前这个属性DOMPurify默认打算保留!!截图里 onerror 这一条:keepAttr: true 也就是说:单纯配置 REMOVE_ATTR 在你当前这个 DOMPurify 版本,没有成功把 onerror 标记为禁止属性,钩子没命中,hasDanger 不会变成true,属性保留,弹窗触发。

两种修复方式(推荐用钩子强制拦截事件属性,优先级最高)

原理:在 uponSanitizeAttribute 钩子里面,主动强制把所有 on 事件属性拒绝,设置 keepAttr = false*,这个钩子是在属性校验阶段执行,比配置表优先级更高。

修正后的 xssCleanHtml 核心代码
TypeScript 复制代码
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 }
}
重点解释截图里的字段
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 返回逻辑确认

你必须确认:

TypeScript 复制代码
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)>,在钩子内打印:

TypeScript 复制代码
console.log('属性信息', attr)
console.log('要删除吗', /^on/i.test(attr.attrName))

预期:

  1. 命中正则,attr.keepAttr = false
  2. hasDanger = true
  3. 返回的 safeHtml 里面的 img 不再带有 onerror 属性

兜底方案(如果改完钩子,luckysheet还是渲染原始内容)

Luckysheet 部分版本的 rangePasteBefore 钩子有bug,修改 pasteData.content 不生效。 这种情况:

  1. 先CSP头兜底 script-src 'self',浏览器层面阻止事件执行(就算DOM上挂了onerror,也不会执行弹窗)
  2. 叠加编辑框原生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 主要代码部分:

相关推荐
切糕师学AI1 小时前
el-select-v2 深度解析:用虚拟滚动解决海量数据下拉选择性能瓶颈
前端·vue.js·elementui·el-select-v2
徐小夕2 小时前
JitOffice:把办公、AI 与审批装进同一个平台
前端·vue.js·github
徐小夕2 小时前
万字分享:为什么我们要自研Word解析引擎?JitWord 解析引擎 4.0 深度复盘
前端·vue.js·github
陈随易2 小时前
副业搞钱点子之独立开发
前端·后端·程序员
console.log('npc')2 小时前
问题跟踪与知识沉淀:从任务到知识的闭环)
前端·javascript·人工智能
恋猫de小郭2 小时前
最近 Android 有什么更新:Android Dev Summit 2026 技术解读
android·前端·flutter
计算机魔术师2 小时前
Reddit 宣布转向以登录为主的体验,以应对 AI 搜索带来的流量下滑
前端
熊猫钓鱼>_>2 小时前
上架审核常见驳回与隐私合规清单:过审前先自查这 8 条【鸿蒙心迹】
开发语言·前端·华为·华为云·harmonyos·openharmony·ai制作
missile1112 小时前
# HDR 算法 ISP 通路客制化方案(G100 / mt6789)
java·前端·javascript