起因
前段时间我在做代码审查,翻到一个富文本渲染的组件,看到这么一行:
vue
<div v-html="md.render(text)"></div>
当时就愣了一下 ------ markdown-it 渲染出来的 HTML 是原样保留标签的。也就是说,如果有人在内容里写了:
markdown
看看这个:<img src=x onerror="alert(document.cookie)">
这段代码会被 原封不动地插入 DOM 并执行。
我顺手 grep 了一下整个项目,发现类似的 v-html 散落在 6 个组件里 ------ 聊天气泡、文档详情、题目解析、内容展示页... 全都是裸奔状态
XSS 是什么
XSS(Cross-Site Scripting,跨站脚本攻击)是指攻击者将恶意脚本注入到网页中,当其他用户访问该页面时,脚本在用户的浏览器里执行。
它能做什么?
- 窃取 Cookie / Token ------
document.cookie一读,session 就被劫持了 - 冒充用户操作 ------ 用你的身份发请求、改密码、转账
- 键盘记录 ------ 监听你输入的每一个字符
- 钓鱼 ------ 在页面上覆盖一个假的登录框
一句话:XSS 让攻击者能在你的页面上执行任意 JavaScript,而 JavaScript 在浏览器里几乎什么都能干。
三种 XSS 类型
存储型 ------ 最狠的一种
恶意脚本被持久化存储到服务器(数据库、文件),其他用户访问页面时自动执行。
攻击者写入 → 服务端存储 → 其他用户浏览 → 脚本执行
典型场景:论坛帖子、用户评论、文档内容、题目解析。任何 "用户提交内容 → 存到后端 → 展示给其他人" 的链路都可能中招。
我项目里就是这种 ------ 用户提交的富文本内容存到数据库,前端取出来直接 v-html 渲染,没有任何过滤。
危害最大,因为攻击者写入一次,所有访问者都会触发。
反射型
恶意脚本通过 URL 参数传递,服务端将参数原样拼进响应页面的 HTML 中。
攻击者构造恶意 URL → 诱骗用户点击 → 服务端将参数嵌入 HTML → 浏览器执行
比如一个搜索页面:
https://example.com/search?q=<script>alert(1)</script>
如果服务端直接把 q 的值拼到页面里:
html
<p>搜索结果: <script>alert(1)</script></p>
就会执行。
和存储型的区别:反射型不入库,只在用户点击恶意链接时触发一次。但配合社工(伪装成正常链接发给你),威力一样大。
DOM 型
完全在浏览器端发生,不经过服务器。恶意脚本通过前端 JavaScript 操作 DOM 时注入。
攻击者构造恶意输入 → 前端 JS 读取输入 → 直接写入 DOM → 脚本执行
我项目里就有个例子 ------ 鼠标悬浮时动态创建 tooltip:
typescript
tooltip.innerHTML = `<div class="title">📄 ${docName}</div>`;
document.body.appendChild(tooltip);
docName 来自用户上传的文件名。如果文件名是 <img src=x onerror=alert(1)>,就直接执行了。
和反射型的关键区别: 反射型需要服务端参与(把恶意输入拼到 HTML 里返回),DOM 型纯靠前端 JS 操作 DOM 就能触发。传统的服务端 WAF(Web 应用防火墙)检测不到 DOM 型 XSS,因为恶意代码压根没经过服务端。
三种类型对比
| 存储型 | 反射型 | DOM 型 | |
|---|---|---|---|
| 恶意代码存在 | 服务端数据库 | URL 参数 | 前端 DOM |
| 经过服务端 | 是 | 是 | 否 |
| 触发方式 | 访问页面就触发 | 点击恶意链接 | 前端 JS 操作触发 |
| 影响范围 | 所有访问者 | 点击链接的人 | 取决于场景 |
| 危害程度 | 最高 | 中 | 中 |
能执行 JS 的不只是 <script>
很多人以为 XSS 就是注入 <script> 标签。其实攻击向量远比你想的多:
html
<!-- 事件处理器 -->
<img src=x onerror="alert(1)">
<svg onload="alert(1)">
<input onfocus="alert(1)" autofocus>
<body onload="alert(1)">
<details open ontoggle="alert(1)">
<marquee onstart="alert(1)">
<video src=x onerror="alert(1)">
<!-- javascript: 协议 -->
<a href="javascript:alert(1)">点击</a>
<iframe src="javascript:alert(1)">
<!-- data: 协议 -->
<a href="data:text/html,<script>alert(1)</script>">点击</a>
<!-- CSS 表达式(仅旧版 IE) -->
<div style="background:url('javascript:alert(1)')">
<!-- SVG 内嵌脚本 -->
<svg><script>alert(1)</script></svg>
这就是为什么简单的正则过滤(比如替换 <script>)不够用 ------ 你得用专业的净化库。
防御手段
1. 输出编码(最基础)
根据内容插入的位置,用对应的编码方式转义:
| 输出位置 | 编码方式 | 示例 |
|---|---|---|
| HTML 正文 | HTML 实体编码 | < → <,> → > |
| HTML 属性 | HTML 实体编码 | " → " |
| JavaScript 字符串 | JS 转义 | ' → \',\ → \\ |
| URL 参数 | URL 编码 | < → %3C |
| CSS 值 | CSS 转义 | 反斜杠转义 |
在 Vue 里,{``{ }} 插值会自动做 HTML 实体编码,所以它是天然安全的:
vue
<!-- 安全:Vue 自动转义 -->
<div>{{ userInput }}</div>
<!-- 输出: <script>alert(1)</script> -->
<!-- 页面显示的是文本,不会执行 -->
<!-- 危险:绕过了 Vue 的转义 -->
<div v-html="userInput"></div>
<!-- 输出: <script>alert(1)</script> -->
<!-- 脚本会执行! -->
原则:能用 {``{ }} 就绝不用 v-html。
我项目里有个消息列表,只是展示纯文本摘要,不知道当初为什么用了 v-html:
vue
<!-- 改前 -->
<div class="card-desc" v-html="truncateContent(msg.content)" />
<!-- 改后 -->
<div class="card-desc">{{ truncateContent(msg.content) }}</div>
一行改动,从根源上消除了 XSS 的可能。
2. DOMPurify 净化(必须用 v-html 时)
有些场景确实需要渲染 HTML ------ Markdown 内容、富文本编辑器的输出。这时候用 DOMPurify:
bash
pnpm add dompurify
pnpm add -D @types/dompurify
typescript
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
const md = new MarkdownIt();
// 修复前:裸渲染
const renderMarkdown = (text: string) => md.render(text);
// 修复后:净化
const renderMarkdown = (text: string) => DOMPurify.sanitize(md.render(text));
vue
<!-- 模板里也一样 -->
<div v-html="DOMPurify.sanitize(content)"></div>
DOMPurify 的工作原理:
它不是简单的字符串替换。它会把 HTML 解析成 DOM 树,然后遍历每个节点:
- 移除危险标签:
<script>、<iframe>、<object>、<embed>等 - 移除危险属性:
onerror、onclick、onload等所有事件处理器 - 移除危险协议:
href="javascript:..."、src="data:..." - 保留安全的格式化标签:
<p>、<b>、<code>、<pre>、<img>(去掉事件属性) 等
| 输入 | DOMPurify 输出 |
|---|---|
<img src=x onerror=alert(1)> |
<img src="x"> |
<script>alert(1)</script> |
(空) |
<a href="javascript:alert(1)">点击</a> |
<a>点击</a> |
<svg onload=alert(1)> |
<svg></svg> |
<b>正常加粗</b> |
<b>正常加粗</b> |
你也可以配置白名单,只允许特定标签和属性:
typescript
DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title'],
});
动态创建 DOM 时也别忘了:
typescript
// 修复前
tooltip.innerHTML = `<div class="title">📄 ${docName}</div>`;
// 修复后
tooltip.innerHTML = DOMPurify.sanitize(
`<div class="title">📄 ${docName}</div>`
);
// 或者更好的做法:如果不需要 HTML,用 textContent
tooltip.textContent = docName; // 绝对安全
3. URL 协议校验
javascript:alert(1) 是一个语法上合法的 URL。如果你把来自外部的 URL 直接赋给 href 或 src,就可能执行恶意代码。
typescript
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return ["http:", "https:"].includes(parsed.protocol);
} catch {
return false;
}
}
// 使用
const url = externalData.url;
if (!isSafeUrl(url)) {
console.warn("不安全的 URL,已拦截:", url);
return;
}
我项目里有个通过 WebSocket 接收资源地址的功能,之前完全信任推过来的 URL。加了这个校验后,javascript:、data:、vbscript: 这些危险协议就全被拦了。
4. CSP(Content Security Policy)
CSP 是通过 HTTP 响应头告诉浏览器 "只允许执行来自这些来源的脚本":
Content-Security-Policy: script-src 'self' https://cdn.example.com;
这意味着:
'self'------ 只允许同源的脚本https://cdn.example.com------ 允许这个 CDN 的脚本- 内联脚本(
<script>alert(1)</script>)------ 默认禁止 javascript:URL ------ 默认禁止
CSP 是 XSS 的最后一道防线。 即使攻击者成功注入了 <script> 标签,浏览器也会因为 CSP 策略拒绝执行。
常用指令:
| 指令 | 作用 |
|---|---|
script-src |
控制 JS 脚本来源 |
style-src |
控制 CSS 来源 |
img-src |
控制图片来源 |
connect-src |
控制 XHR / WebSocket / fetch 的目标 |
frame-src |
控制 iframe 来源 |
default-src |
其他指令的默认值 |
5. HttpOnly Cookie
设置 HttpOnly 标志的 Cookie,JavaScript 无法通过 document.cookie 读取:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
这不能防止 XSS 本身,但能减小 XSS 的危害 ------ 即使攻击者执行了脚本,也偷不走你的 session cookie。
6. 服务端入库过滤
前端的防御可以被绕过(比如攻击者直接调 API)。所以服务端在数据入库前也应该做一道过滤:
go
// Go 语言示例
import "github.com/microcosm-cc/bluemonday"
p := bluemonday.UGCPolicy() // 用户生成内容策略
safeHTML := p.Sanitize(userInput)
javascript
// Node.js 示例
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const clean = DOMPurify.sanitize(dirty);
原则:前端过滤是为了用户体验(即时反馈),服务端过滤是为了安全(不可绕过)。两层都要做。
几个认知误区
"Vue 不是自动防 XSS 吗?"
{``{ }} 确实安全。但你用了 v-html 就等于告诉 Vue:"别管了,我自己来。" 然后你自己又没管。
"后端过滤了,前端不用管"
我们的 WebSocket 推送没有经过后端的 HTML 过滤(因为它不是 HTTP 接口),前端直接信任了消息内容。信任边界不在你以为的地方。
"加了 DOMPurify 就完事了"
DOMPurify 防的是 HTML/DOM 注入。它管不了:
javascript:协议的 URL(得单独校验)- 服务端模板注入
- HTTP 响应头注入
"只有 <script> 才能执行 JS"
上面列了十几种攻击向量,事件处理器、javascript: URL、SVG... 简单的字符串替换根本防不住。
最后
修完之后我最大的感受是:XSS 防御不难,难的是养成习惯。 每次写 v-html 的时候多想一秒 "这个数据可信吗",大部分问题就不会出现。
一句话总结:永远不要信任外部输入,哪怕它来自你自己的后端。