☰
记录修复LuckySheet安全测试XSS漏洞问题
2026/10/13 10:58:40 网站建设 项目流程

背景:

项目安全测试工程师最近测出项目里的一些安全漏洞问题,基本就是通过HTML标签渲染会加载编译一些带XSS攻击的属性,自己所负责的模块LuckySheet在线共享编辑也会有诸如此类的问题所在,所以特意在此记录总结一下解决过程。


测试问题:

一、存储型跨站脚本攻击

漏洞描述:

跨站脚本攻击的英文全称是CrossSiteScript,为了和样式表区分,缩写为XSS。发生的原因是网站将用户输入的内容输出到页面上,在这个过程中可能有恶意代码被浏览器执行。跨站脚本攻击,它指的是恶意攻击者往Web页面里插入恶意html代码,当用户浏览该页之时,嵌入其中Web里面的html代码会被执行,从而达到恶意用户的特殊目的。


漏洞测试案例1:

输入payload:

在登录后点击办公类工具中的Excel共享编辑功能-新建,在表格内容中

<img src=x οnerrοr=confirm(1)>如下图:

点击返回再点击刚刚新建的表格访问触发弹窗


询问:我​​现在的问题是:项目使用 luckysheet 在表格内容中输入 payload:<img src=x οnerrοr=confirm(1)>,luckysheet 表格会识别编译并触发弹窗,这已经属于安全漏洞问题了,帮我看一下怎么处理呢?

Gemini 回复:

针对 Luckysheet 表格中输入的 payload 被识别并触发弹窗(存储型 XSS 漏洞)的问题,可以通过数据过滤、转义以及浏览器安全策略进行综合修复。以下是具体的处理步骤:
1、对表格数据进行 HTML 转义或净化:核心修复方案。

在将数据加载进 Luckysheet 渲染,或者在 保存 / 读取 表格 JSON 数据时,使用 文本转义逻辑 或 安全净化库(如DOMPurify)对单元格内容进行过滤,将<、>等特殊字符转义为 HTML 实体,确保内容作为纯文本呈现。

验证方式:重新在单元格中输入<img src=x onerror=confirm(1)>,表格中应直接展示该字符串文本,而不会触发弹窗。
2、配置 Luckysheet 钩子函数拦截:前端输入拦截。

在前端初始化 Luckysheet 或 监听单元格编辑、数据变动事件(如相关回调钩子)时,加入对用户输入内容的正则校验或 标签剔除 逻辑,防止恶意 HTML 标签入库。

验证方式:尝试在单元格输入含有标签的文本并保存,检查提交的数据是否已被 清洗 或 阻止。
3、部署内容安全策略 (CSP):纵深防御。

在Web 服务器或后端响应头中配置严格的Content-Security-Policy(CSP),限制内联脚本 和 未授权脚本的执行,从浏览器底层阻断 XSS 攻击。

验证方式:打开浏览器开发者工具的 Console 控制台,确认恶意 payload 触发时被 CSP 策略拦截且无法执行。


续问 Gemini:

请提供一段使用 DOMPurify 或转义处理 Luckysheet 数据的具体前端代码示例。

Gemini 回复:

在 Vue 3 或 前端项目中处理 Luckysheet 的 XSS 漏洞,推荐使用成熟的安全净化库DOMPurify。在将数据加载进 Luckysheet 或 从后端 获取 / 提交 表格数据时,对单元格的文本值进行清洗或转义。

以下是具体的代码实现示例:

1. 安装 DOMPurify

在项目中执行安装:

npm install dompurify npm install --save-dev @types/dompurify

"dompurify": "^3.4.16",

"@types/dompurify": "^3.0.5",

2. 编写数据清洗工具函数

Luckysheet 的数据通常是一个包含多个 sheet 的数组,每个 sheet 内部通过二维数组data存储单元格对象。可以编写一个递归或循环遍历的清洗函数:

import DOMPurify from 'dompurify'; /** * 清洗 Luckysheet 的表格数据,防止存储型 XSS * @param sheetsData Luckysheet 的完整的 JSON 数据(数组格式) */ export function sanitizeLuckysheetData(sheetsData: any[]): any[] { if (!Array.isArray(sheetsData)) return sheetsData; return sheetsData.map(sheet => { // Luckysheet 的单元格矩阵存储在 sheet.data 中 if (sheet.data && Array.isArray(sheet.data)) { sheet.data.forEach((row: any[]) => { if (Array.isArray(row)) { row.forEach((cell: any) => { if (cell && typeof cell === 'object') { // 清洗单元格的实际值 (v) if (typeof cell.v === 'string') { // 使用 DOMPurify 过滤所有 HTML 标签,转为纯文本 cell.v = DOMPurify.sanitize(cell.v, { ALLOWED_TAGS: [] }); } // 清洗单元格的显示值 (m) if (typeof cell.m === 'string') { cell.m = DOMPurify.sanitize(cell.m, { ALLOWED_TAGS: [] }); } } }); } }); } return sheet; }); }

3. 在组件中集成(以 Vue 3 为例)

在初始化表格 (luckysheet.create) 之前,或者在用户 导入 / 加载数据时调用该清洗函数:

<script setup lang="ts"> import { onMounted } from 'vue'; import { sanitizeLuckysheetData } from '@/utils/sanitizeSheet'; // 假设这是从后端获取到的原始表格数据 const fetchTableDataFromBackend = async () => { // 模拟接口返回带有恶意 payload 的数据 const rawData = [ { name: "sheet1", data: [ [ { v: "<img src=x onerror=confirm(1)>", m: "<img src=x onerror=confirm(1)>" } ] ] } ]; // 1. 在渲染前进行安全清洗 const safeData = sanitizeLuckysheetData(rawData); return safeData; }; onMounted(async () => { const cleanData = await fetchTableDataFromBackend(); // 2. 初始化 Luckysheet (window as any).luckysheet.create({ container: 'luckysheet', // 容器 ID data: cleanData, // 使用清洗后的安全数据 }); }); </script> <template> <div id="luckysheet" style="width: 100%; height: 800px;"></div> </template>

💡 核心防御点提示:

  • 入库与渲染双保险:除了在前端渲染前清洗,建议在后端接收保存请求时同样使用类似的安全策略,防止绕过前端直接向数据库写入恶意代码。

  • 如果希望用户在表格中看到原样的标签字符(如字面量<img...>)而不是被完全清空,也可以使用传统的 HTML 字符实体转义函数替代DOMPurify。


🛠️ 正确的rawData结构写法

Luckysheet 接收的data应当是一个工作表(Sheet)对象的数组,其中每个工作表对象内部包含name和celldata。

请将edit.vue中的fetchTableDataFromBackend修改为标准的 Luckysheet 工作表结构:

const fetchTableDataFromBackend = async () => { // 模拟接口返回带有恶意的 payload 数据(必须符合 Luckysheet 的 Sheet 结构) const rawData = [ { name: "Sheet1", // 必须有工作表名称,否则底部会显示异常 celldata: [ { r: 0, c: 0, v: { v: "欢迎使用", ct: { fa: "@", t: "s" }, m: "欢迎使用" } }, { r: 0, c: 1, v: { v: "Luckysheet", ct: { fa: "@", t: "s" }, m: "Luckysheet" } }, { r: 0, c: 2, v: { v: "<img src=x onerror=confirm(1)>", ct: { fa: "@", t: "s" }, m: "<img src=x onerror=confirm(1)>" } }, { r: 2, c: 2, v: { v: "<img src=x onerror=confirm(1)>", ct: { fa: "@", t: "s" }, m: "<img src=x onerror=confirm(1)>" } } ] } ]; // 1. 在渲染前进行安全清洗 const safeData = sanitizeLuckysheetData(rawData); return safeData; };

修改完rawData的数据结构后,重新刷新页面:

  • 下方的 Sheet 名称会正确显示为"Sheet1"(不再是${name})。

  • 单元格中会正常带入测试数据,并且因为经过了DOMPurify清洗,恶意 payload 会被作为安全文本展示,不会再触发弹窗漏洞。


追问:不是吧,但这效果咋看着像是直接把我第三第四条数据删了呢?

导致第三、四条数据(也就是你在celldata中配置的坐标位置数据)在页面上“消失”或变成空白的原因是:DOMPurify.sanitize(..., { ALLOWED_TAGS: [] })的默认行为是将所有未被允许的 HTML 标签(如<img>)以及标签内部的危险结构全部直接剔除/过滤空。

由于<img src=x onerror=confirm(1)>整个字符串全是由 HTML 标签构成的,当配上{ ALLOWED_TAGS: [] }后,DOMPurify认为里面没有任何合法的 HTML 标签,于是把整个字符串过滤成了空字符串"",所以单元格看起来就像是被删掉了一样空空如也。

正确的修复方案

如果你希望在表格里保留字面量文本(即在单元格中清晰地看到<img src=x onerror=confirm(1)>这行字符串,而不会被过滤成空白,同时又不会让浏览器触发 XSS 执行),我们需要做的是HTML 实体转义(Escaping),而不是用DOMPurify把标签删掉。

你可以用一个纯文本的安全转义函数来替代DOMPurify:

1. 编写安全的转义函数(在utils/sanitizeSheet.ts中)
/** * 将特殊字符转义为 HTML 实体,防止 XSS 攻击的同时保留文本字面量展示 */ function escapeHTML(str: string): string { if (typeof str !== 'string') return str; return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); } export function sanitizeLuckysheetData(sheetsData: any[]): any[] { if (!Array.isArray(sheetsData)) return sheetsData; return sheetsData.map(sheet => { // 1. 处理 celldata 格式 if (sheet.celldata && Array.isArray(sheet.celldata)) { sheet.celldata.forEach((item: any) => { if (item && item.v) { if (typeof item.v.v === 'string') { item.v.v = escapeHTML(item.v.v); } if (typeof item.v.m === 'string') { item.v.m = escapeHTML(item.v.m); } } }); } // 2. 处理标准二维数组 data 格式 if (sheet.data && Array.isArray(sheet.data)) { sheet.data.forEach((row: any[]) => { if (Array.isArray(row)) { row.forEach((cell: any) => { if (cell && typeof cell === 'object') { if (typeof cell.v === 'string') { cell.v = escapeHTML(cell.v); } if (typeof cell.m === 'string') { cell.m = escapeHTML(cell.m); } } }); } }); } return sheet; }); }

为什么这样改就能解决?

  • 转义而不是删除:escapeHTML会把<转义为&lt;,把>转义为&gt;。

  • 这样当 Luckysheet 渲染时,浏览器看到的是安全转义后的普通文本(即图二的效果:显示<img src=x onerror=confirm(1)>字面量),而不是一个真正的<img>标签,从而既不会丢失数据,又彻底堵住了 XSS 漏洞。


⚠️注意:这里有一个问题就是

LuckySheet在选中转义完的输入框时,会在上方 FX 栏又转义回去!

假如你聚焦上去了,就会导致下方输入框的内容也同步变回去了,最后就是“白折腾”。。

So,所以实在不行的话还是老老实实用人家成熟的安全净化库DOMPurify 方式吧。。


下一个问题:

我模仿用户行为,直接将<img src=x οnerrοr=confirm(1)>复制粘贴进LuckySheet表格内,也会触发弹窗提示,这种怎么处理呢?


用户直接通过前端复制粘贴输入恶意 Payload 触发弹窗,本质上是因为 Luckysheet 在处理剪贴板粘贴(paste事件)、单元格输入编辑(cellUpdate或 监听输入框变化)时,没有对实时输入的内容做 拦截 和 转义。

要解决 复制粘贴 触发 XSS 的问题,需要从Luckysheet 钩子函数(Hooks)拦截以及单元格编辑完成后的 数据净化两个维度入手:

解决方案:利用 Luckysheet 的钩子函数拦截粘贴与修改

Luckysheet 提供了丰富的配置钩子(Hooks),我们可以在配置项中监听用户对 单元格 的修改、粘贴 或 数据变化事件。

1. 核心思路
  • 监听cellUpdated、paste相关的事件,或者在 数据提交、单元格 失去焦点、存入表格前,对单元格中的文本实时进行escapeHTML转义。

  • 也可以通过劫持 Luckysheet 的 单元格编辑保存钩子,将用户输入的危险 HTML 字符直接 过滤 或 转义。

2. 代码实现示例(在edit.vue中配置钩子)

在luckysheet.create的options中引入钩子函数:

import { escapeHTML } from '@/utils/sanitizeSheet'; // 引入前面写好的转义函数 const initLuckysheet = async () => { const cleanData = await fetchTableDataFromBackend(); const options = { container: 'luckysheet', title: 'Vue3 Vite Luckysheet 示例', lang: 'zh', allowUpdate: false, data: cleanData, // 【关键点】:配置 Luckysheet 钩子函数,拦截用户后续的输入与粘贴 hook: { // 当单元格数据发生改变(包括手动输入、粘贴、公式计算等)时触发 cellUpdated: (r: number, c: number, oldvalue: any, newvalue: any, iscut: boolean) => { if (newvalue && typeof newvalue.v === 'string') { // 对新输入/粘贴的内容进行 HTML 转义 const safeText = escapeHTML(newvalue.v); // 如果转义后的文本和原文本不一致(说明包含了被转义的特殊字符), // 可以通过 luckysheet API 回写安全的值,或者阻止其作为 HTML 解析 if (safeText !== newvalue.v) { console.warn(`检测到潜在 XSS 攻击风险,已自动转义单元格 [${r}, ${c}] 的输入内容`); // 使用 luckysheet API 更新对应单元格的值为安全文本 // (window as any).luckysheet.setCellValue(r, c, safeText); } } }, // 如果 Luckysheet 版本支持 paste 钩子,也可以直接在 paste 时拦截 pasteBefore: (pasteData: any) => { // 可以在这里对粘贴进来的所有数据进行批量 escapeHTML 处理 return pasteData; } } }; (window as any).luckysheet.create(options); };

💡 更彻底的加固建议:

  1. 配合后端进行最终把关:前端的拦截和转义主要是为了提供良好的交互和防范第一层风险。当用户在表格里编辑完并保存(提交到后端)时,后端也必须对接收到的单元格数据进行严格的转义或清洗。这样即使前端的某个钩子被绕过,后端存入数据库的数据也是安全的。

  2. 全局文本渲染属性(Ct)控制:在 Luckysheet 的数据结构中,可以通过设置单元格类型ct: { fa: "@", t: "s" }(明确指定为文本类型字符串t: "s"),强制表格将其作为纯文本渲染,而不是让其解析富文本或 HTML 标签。


不太行,有两点,一是用户直接选中单元格不聚焦粘贴,并不会触发cellUpdated钩子函数,二是用户聚焦进入输入框粘贴还是会直接弹窗,并且safeText 的值会变成一堆如图所示的东西且死循环


从你的截图中可以非常清晰地看到两个典型的问题:

  1. 死循环与&amp;无限嵌套:因为在cellUpdated中调用setCellValue再次修改了单元格的值,这会再次触发cellUpdated钩子。而在第二次触发时,原本已经被转义成&lt;的文本,中的&符号又被escapeHTML进行了二次转义(变成&amp;lt;),导致无限套娃,最终变成了一长串&amp;amp;amp;...的死循环。

  2. 不聚焦直接粘贴无法拦截:说明 Luckysheet 的底层粘贴事件没有完全暴露给cellUpdated。

要彻底解决这两个问题,我们需要避开在钩子里直接调用修改 API 造成死循环的陷阱,转而在浏览器 原生的粘贴、键盘事件或者在Luckysheet 渲染 / 获取数据源的根源上进行拦截。

正确的解决思路

既然直接改单元格会陷入死循环,最好的办法是全局 监听 粘贴事件(paste)和 键盘 输入事件,或者在用户最终触发 保存 / 同步数据时统一清洗。但为了做到即时拦截弹窗,我们可以通过给 Luckysheet 的容器绑定原生paste监听,或者在单元格进入编辑状态时进行管控。

这里有一个更稳妥的方案——通过全局监听容器的paste事件,并在 Luckysheet 内部数据渲染前拦截:

方案:在容器层拦截原生粘贴并清洗数据

你可以在 Vue 组件中,通过对 Luckysheet 挂载的 DOM 容器监听原生的paste事件,直接阻止默认带有恶意标签的粘贴行为,或者将其转换为纯文本。

修改edit.vue,在onMounted中为 Luckysheet 容器绑定一层原生的粘贴防护:

import { onMounted, onBeforeUnmount } from 'vue'; import { sanitizeLuckysheetData } from '@/utils/sanitizeSheet'; // 阻止危险粘贴的通用纯文本转换函数 function handlePasteText(e: ClipboardEvent) { // 获取剪贴板中的纯文本 const text = e.clipboardData?.getData('text/plain'); if (text && (text.includes('<') || text.includes('>'))) { // 如果发现包含 HTML 标签特征,阻止默认粘贴行为 e.preventDefault(); // 转换为安全文本 const safeText = text .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); // 使用浏览器自带的 API 将清洗后的文本写入(或者提示用户) // 也可以通过选中的激活单元格直接写入安全值 document.execCommand('insertText', false, safeText); console.warn("已拦截通过剪贴板输入的潜在 XSS 攻击代码"); } } onMounted(() => { if ((window as any).luckysheet) { initLuckysheet(); // 【关键点】:对 Luckysheet 的渲染容器绑定全局 paste 捕获 const container = document.getElementById('luckysheet'); if (container) { container.addEventListener('paste', handlePasteText, true); // true 表示在捕获阶段拦截 } } }); onBeforeUnmount(() => { const container = document.getElementById('luckysheet'); if (container) { container.removeEventListener('paste', handlePasteText, true); } });

为什么这个方案能解决你的痛点?

  1. 彻底终结死循环:我们不再使用会引起递归触发的cellUpdated钩子去回写表格,而是直接在浏览器底层的paste捕获阶段对剪贴板数据进行纯文本化。

  2. 无论是否聚焦都能拦截:通过在外层container监听paste(利用事件捕获机制true),即使用户没有点进输入框内部,只是选中单元格直接粘贴,也能在数据写入表格的一瞬间被拦截并转义,从而避免触发弹窗。


没触发paste事件是因为 Luckysheet 内部是基于 Canvas 绘制和自定义的虚拟 DOM/输入框渲染的,直接对最外层div绑定paste无法完美捕获它内部动态生成的编辑输入框。而至于之前出现一堆&amp;的死循环,是因为直接调用了修改单元格 API 导致了事件递归触发。

针对 Luckysheet 这类基于 Canvas 的表格,最稳妥、最标准的防御方式是在数据加载前、用户保存(提交)时,以及利用 Luckysheet 提供的生命周期钩子在表格内部进行批量清洗。

你可以通过以下两种更有效的方式来彻底堵住复制粘贴和输入的漏洞:

方案一:在数据保存/获取时进行全局统一净化(最推荐、最稳妥)

无论用户怎么复制粘贴、怎么输入,只要他在表格里编辑完准备提交给后端保存或者前端同步数据时,对整张表格的数据做一次统一的 HTML 转义清洗。这样既不会影响用户在表格里的即时输入体验,又绝对不会发生弹窗和 XSS 漏洞。

修改edit.vue,编写一个统一的净化方法并在保存时调用:

import { sanitizeLuckysheetData } from '@/utils/sanitizeSheet'; // 模拟保存表格数据到后端 const handleSaveTableData = () => { // 1. 获取 Luckysheet 当前的全部数据 const currentData = (window as any).luckysheet.getAllSheets(); // 2. 提交前进行安全清洗(将所有恶意标签转义为安全字面量) const safeData = sanitizeLuckysheetData(currentData); // 3. 发送给后端保存 console.log("准备提交的安全数据:", safeData); // axios.post('/api/save', { data: safeData }) };

方案二:利用 Luckysheet 的内置钩子拦截编辑完成(CellEditBefore / cellUpdated)

如果你希望在用户刚填完一个单元格失去焦点时就立刻处理,可以监听 Luckysheet 的编辑结束钩子。

为了防止前面出现的&amp;死循环,我们绝对不能在里面调用setCellValue再次触发修改,而是直接用正则把危险的尖括号替换掉:

const initLuckysheet = async () => { const cleanData = await fetchTableDataFromBackend(); const options = { container: 'luckysheet', title: 'Vue3 Vite Luckysheet 示例', lang: 'zh', allowUpdate: false, data: cleanData, hook: { // 监听单元格编辑结束/内容改变 cellUpdated: (r: number, c: number, oldvalue: any, newvalue: any) => { if (newvalue && typeof newvalue.v === 'string') { // 检查是否包含危险的 HTML 标签特征 if (/<[a-z][\s\S]*>/i.test(newvalue.v)) { console.warn(`检测到单元格 [${r}, ${c}] 包含 HTML 标签,正在转义`); // 直接在内存中对新值做单次转义(不调用 API 写回,避免死循环) newvalue.v = newvalue.v .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;'); if (newvalue.m) { newvalue.m = newvalue.v; } } } } } }; (window as any).luckysheet.create(options); };

为什么安全扫描/渗透测试会测出这个漏洞?

安全工具(如 Burp Suite 或渗透测试人员)提交<img src=x onerror=confirm(1)>的目的,是验证系统在将该数据展示给其他用户(或自己二次访问)时,浏览器会不会执行它。

  • 只要你在渲染加载时(如前面提供的sanitizeLuckysheetData)和最终保存入库时对数据进行了 HTML 实体转义(把<转成&lt;),那么纵使恶意代码被复制粘贴进了数据库,当页面重新加载或别人打开时,浏览器也只会把它当成普通的文本字符串显示出来,绝不会再触发弹窗或执行任何脚本。


最终复制粘贴还是会弹窗,但是聚焦粘贴完会转义好,但依旧会有点子问题。。。


询问Gemini:

为啥腾讯在线文档表格不管是直接粘贴,还是双击输入粘贴,还是手动输入粘贴都不会有问题,不会弹窗,他们是怎么做到的呢?能否运用到我LuckySheet中来呢?


腾讯在线文档之所以能够做到无论怎么粘贴、怎么输入都非常安全(只显示字面量文本,不会触发弹窗),是因为它们作为成熟的商业级在线协作文档,从底层架构上遵循了几个核心的安全设计原则。

这些原理完全可以借鉴并应用到你的 Luckysheet 项目中。

一、 腾讯在线文档是怎么做到的?

  1. 严格的“数据与视图隔离”(底层渲染安全)

    • 腾讯在线文档(以及 Google Sheets 等)在渲染单元格时,绝不使用危险的innerHTML直接把用户的输入塞进网页。它们的表格核心区域通常基于Canvas 渲染或严格的虚拟 DOM 文本节点(createTextNode)。

    • 在 Canvas 或纯文本节点中,<img src=x onerror=confirm(1)>只是普通的一串字符串像素,浏览器绝对不会把它当成 HTML 标签去解析和执行。

  2. 剪贴板内容的“降维打击”(剥离 HTML 格式)

    • 当用户从别处复制一段内容(网页上的富文本或带标签的代码)时,剪贴板里其实同时包含text/plain(纯文本)和text/html(富文本格式)。

    • 腾讯文档在监听粘贴事件时,只强行提取并接收text/plain(纯文本),直接把所有text/html或富文本标签全部丢弃或转义。因此,你粘贴进去的永远只是纯文本字面量。

  3. 结构化的富文本存储(非原始 HTML 字符串)

    • 像表格这种支持部分单元格内样式或富文本的系统,如果允许插入图片或超链接,它们绝不会允许用户直接输入原始的 HTML 标签(如<img ...>),而是通过自定义的安全 JSON 结构(如富文本 Delta 语法)来描述。非富文本区域一律按纯文本处理。

二、 能否运用到你的 Luckysheet 中?如何实现?

答案是:完全可以。既然 Luckysheet 内部的某些输入或粘贴逻辑会把用户的输入当作 HTML 或触发解析,我们可以通过“全局剪贴板劫持”和“输入源头过滤”来对 Luckysheet 进行加固。

你可以采取以下两种落地改造方案:

方案 1:全局劫持粘贴事件,强制只允许粘贴“纯文本”

因为用户反馈在 Luckysheet 内部组件上直接绑 paste 捕获不到,我们可以直接在整个页面(document)级别拦截复制粘贴事件。当用户在表格区域粘贴时,强行剥离 HTML,只把纯文本喂给 Luckysheet:

在你的edit.vue的onMounted中加入全局粘贴拦截:

// 全局拦截粘贴,防止富文本/HTML标签注入 Luckysheet const handleGlobalPaste = (e: ClipboardEvent) => { // 检查当前焦点是否在 Luckysheet 区域内 const target = e.target as HTMLElement; if (target && (target.closest('#luckysheet') || target.closest('.luckysheet-input-box'))) { // 阻止默认的富文本/HTML粘贴行为 e.preventDefault(); // 只获取纯文本内容 const plainText = e.clipboardData?.getData('text/plain') || ''; if (plainText) { // 对纯文本进行 HTML 实体转义(安全处理) const safeText = plainText .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); // 使用浏览器自带的安全命令将处理后的纯文本插入当前激活的输入框/单元格 document.execCommand('insertText', false, safeText); } } }; onMounted(() => { // 挂载全局捕获 document.addEventListener('paste', handleGlobalPaste, true); }); onBeforeUnmount(() => { document.removeEventListener('paste', handleGlobalPaste, true); });
方案 2:双保险——提交/保存时统一做 HTML 转义清洗

正如前面所分析的,即使某些边界情况漏掉了前端的实时拦截,只要保证数据在入库保存前、或者在加载渲染前,经过统一的转义函数处理(将<转为&lt;),那么:

  • 用户输入时看到的是<img...>的字面量(如图二)。

  • 浏览器拿到的是&lt;img...&gt;,绝对不会触发任何 XSS 弹窗。

  • 安全扫描工具(渗透测试)再来检测时,由于所有的输入都被当成了安全的纯文本实体,漏洞就会直接闭环修复。


追问:

好像不太行,有两点感觉没生效,一是e.preventDefault()感觉并没有阻止其粘贴啊,还是弹窗了,二是数据清洗好了也并没有插入当前激活的输入框/单元格


从你提供的截图可以看出:

  1. plainText成功打印出来了,说明监听生效了。

  2. safeText也成功转义成了&lt;img src=x onerror=confirm(1)&gt;。

  3. 但是依然弹窗了,并且表格里写入的还是带尖括号的原始<img>标签。

为什么会这样?

因为 Luckysheet 内部有一套独立于原生 DOM 的事件调度和剪贴板解析机制(它使用了自己内部的paste监听或底层数据流)。当你调用e.preventDefault()时,只能阻止浏览器的默认粘贴行为,但 Luckysheet 内部可能在更早或更深的阶段已经通过异步或者独立的事件捕获拿到了原始的剪贴板数据并完成了渲染。

真正的破局点在哪里?

既然在“粘贴瞬间”去拦截 Luckysheet 内部复杂的底层事件非常困难,那我们不如换一个更可靠、更官方的思路:让用户正常粘贴,但在 Luckysheet 触发数据变更(或者失去焦点/保存入库)的一瞬间,利用 API 或者是我们在前面学到的“清洗函数”进行拦截与重写。

其实前面我们提到过,为什么腾讯文档不会弹窗?因为腾讯文档在底层渲染时,绝不会把字符串当成 HTML 去解析。而 Luckysheet 作为一个开源表格,它在某些版本的底层渲染或公式/富文本处理时,如果不加管控,确实会把特定格式的字符串当成 HTML 执行。

为了彻底、完美地解决这个漏洞,最稳妥的方案是在获取/加载、更新或批量同步数据时,对整张表的数据进行清洗,并利用 Luckysheet 的 API 强制更新单元格格式。

终极解决方案:通过 Luckysheet 的单元格数据更新接口进行清洗

当用户完成粘贴后,我们可以监听cellUpdated或者是通过一个防抖函数,在用户停止输入/粘贴后,直接用 Luckysheet 的 API 把包含危险标签的单元格值重写为转义后的安全文本。

你可以修改 Luckysheet 的配置,加入如下的事件处理:

const initLuckysheet = async () => { const cleanData = await fetchTableDataFromBackend(); const options = { container: 'luckysheet', title: 'Vue3 Vite Luckysheet 示例', lang: 'zh', allowUpdate: false, data: cleanData, hook: { // 监听单元格内容变动(包括粘贴、输入) cellUpdated: (r: number, c: number, oldvalue: any, newvalue: any) => { if (newvalue && typeof newvalue.v === 'string') { // 检查是否包含危险的 HTML 标签(如带有 <... > 的 payload) if (/<[a-z][\s\S]*>/i.test(newvalue.v)) { console.warn(`检测到单元格 [${r}, ${c}] 含有不安全标签,正在强制转义...`); // 1. 转义为安全文本 const safeValue = newvalue.v .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); // 2. 核心:通过 Luckysheet 官方 API 显式更新该单元格,并明确指定类型为纯文本 (string) // 这样可以彻底抹掉它作为 HTML 标签被解析的可能性 setTimeout(() => { (window as any).luckysheet.setCellValue(r, c, safeValue); }, 0); } } } } }; (window as any).luckysheet.create(options); };

为什么这个方案能彻底解决?

  1. 绕过 DOM 粘贴阻拦的限制:不管用户是用什么刁钻的方式(双击输入粘贴、选中直接粘贴、快捷键粘贴)贴进表格,只要 Luckysheet 内部完成了数据落盘并触发了cellUpdated,我们的钩子就能立刻捕获到。

  2. 异步重写消灭弹窗:通过setTimeout异步调用luckysheet.setCellValue将其覆写为安全转义后的字符串。此时浏览器在渲染时拿到的已经是纯文本实体(如&lt;img...&gt;),恶意脚本失去了执行的土壤,弹窗自然就不会再发生了。

  3. 安全扫描(渗透测试)闭环:当安全工具去检测时,由于所有的输入最终都会被安全转换成纯文本字面量并以文本形态展示,漏洞即告修复。


追问:

差不多算是解决了,但是还是会有两个问题,一是选中单元格,不双击聚焦粘贴的话,不会进入钩子函数,并且依旧会弹窗,二是双击聚焦进入编辑状态粘贴完也是会立即弹窗,回车完成编辑才会进入钩子函数并且能够清晰成功并且重写转义写入成功。


这两个现象非常关键,它彻底揭示了 Luckysheet 的底层机制:

  1. 选中单元格直接粘贴:Luckysheet 直接跳过了编辑状态,把剪贴板数据通过内部方法直接写入了单元格,根本没有触发cellUpdated钩子,因此直接触发了弹窗。

  2. 聚焦进入编辑状态粘贴:粘贴进去的瞬间,输入框内部的 DOM 触发了浏览器的 HTML 解析,导致弹窗立即弹出;直到回车结束编辑,cellUpdated钩子才被触发并进行了转义覆盖。

要彻底解决这两个顽疾(即在“粘贴发生的瞬间”或“非聚焦直接写入的瞬间”就将其拦截并转义,绝对不给浏览器任何解析标签和弹窗的机会),我们必须在 Luckysheet 内部执行逻辑的最前端——劫持全局的剪贴板事件,或者在 Luckysheet 内部的粘贴处理函数生效前进行全局清洗。

💡 终极解决方案:在捕获阶段全局拦截粘贴,并直接改写 Luckysheet 的剪贴板输入源

既然直接对#luckysheet容器的paste监听没有生效,是因为 Luckysheet 内部是用 Canvas 和它自己的键盘/粘贴监听器处理的。我们可以直接通过修改浏览器剪贴板数据(ClipboardData),或者在全局paste事件的捕获阶段(Capture Phase)将剪贴板中的 HTML 标签直接“偷梁换柱”替换成安全文本!

当用户在表格区域执行任何粘贴操作时,浏览器会读取剪贴板。我们在最外层利用事件捕获强制修改剪贴板的内容,让 Luckysheet 读到的直接就是转义后的安全文本:

修改edit.vue中的全局粘贴拦截逻辑:
// 终极全局粘贴拦截与净化 const handleUltimatePaste = (e: ClipboardEvent) => { // 获取当前焦点或者检查是否在 Luckysheet 编辑区域内 const activeElement = document.activeElement; const isInsideSheet = activeElement?.closest('#luckysheet'); if (isInsideSheet || (e.target as HTMLElement)?.closest('#luckysheet')) { // 1. 获取剪贴板中的纯文本 const rawText = e.clipboardData?.getData('text/plain'); if (rawText && (rawText.includes('<') || rawText.includes('>'))) { // 2. 阻止默认的危险粘贴与解析行为 e.preventDefault(); e.stopPropagation(); // 3. 立即转换为绝对安全的纯文本实体 const safeText = rawText .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); // 4. 获取当前 Luckysheet 选中的活动单元格坐标,直接用 API 写入安全文本 // 这样无论是“选中未聚焦粘贴”还是“聚焦输入框粘贴”,都能一步到位直接写入转义后的数据! const currentCell = (window as any).luckysheet.getActiveSheet().luckysheet_select_save; if (currentCell && currentCell.length > 0) { const row = currentCell[0].row[0]; const col = currentCell[0].column[0]; // 强制通过 API 写入安全文本,绝不给浏览器触发 HTML 解析的机会 (window as any).luckysheet.setCellValue(row, col, safeText); console.log(`成功在底层拦截并写入安全文本到 [${row}, ${col}]:`, safeText); } } } }; onMounted(() => { if ((window as any).luckysheet) { initLuckysheet(); // 【关键点】:使用捕获阶段(true),在事件到达 Luckysheet 内部前强行截获并重写 document.addEventListener('paste', handleUltimatePaste, true); } }); onBeforeUnmount(() => { document.removeEventListener('paste', handleUltimatePaste, true); });

为什么这个方案能完美解决你的两个问题?

  1. 解决“选中单元格不聚焦直接粘贴”:通过document.addEventListener('paste', ..., true)在事件流的捕获阶段最高优先级拦截,直接阻止了 Luckysheet 内部的危险行为,并通过luckysheet.setCellValue直接将转义后的安全文本写入表格,完美绕过弹窗。

  2. 解决“聚焦编辑粘贴瞬间弹窗”:由于在paste发生的同一瞬间执行了e.preventDefault()并替换成了安全文本写入,浏览器根本没有机会去解析那个<img>标签,自然也就不会在粘贴瞬间触发onerror弹窗。

  3. 双保险结合:结合之前保留的cellUpdated钩子作为兜底,这套组合拳可以做到无论是手动输入、复制粘贴、还是接口导入,都绝对不会再触发 XSS 漏洞

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询