如何在项目中减少 XSS 攻击

起因

前段时间我在做代码审查,翻到一个富文本渲染的组件,看到这么一行:

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 实体编码 <&lt;>&gt;
HTML 属性 HTML 实体编码 "&quot;
JavaScript 字符串 JS 转义 '\'\\\
URL 参数 URL 编码 <%3C
CSS 值 CSS 转义 反斜杠转义

在 Vue 里,{``{ }} 插值会自动做 HTML 实体编码,所以它是天然安全的:

vue 复制代码
<!-- 安全:Vue 自动转义 -->
<div>{{ userInput }}</div>
<!-- 输出: &lt;script&gt;alert(1)&lt;/script&gt; -->
<!-- 页面显示的是文本,不会执行 -->

<!-- 危险:绕过了 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 树,然后遍历每个节点:

  1. 移除危险标签:<script><iframe><object><embed>
  2. 移除危险属性:onerroronclickonload 等所有事件处理器
  3. 移除危险协议:href="javascript:..."src="data:..."
  4. 保留安全的格式化标签:<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 直接赋给 hrefsrc,就可能执行恶意代码。

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 其他指令的默认值

设置 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 的时候多想一秒 "这个数据可信吗",大部分问题就不会出现。

一句话总结:永远不要信任外部输入,哪怕它来自你自己的后端。

相关推荐
Rsun045513 小时前
Vue相关面试题
前端·javascript·vue.js
TON_G-T3 小时前
前端包管理器(npm、yarn、pnpm)
前端
卤炖阑尾炎3 小时前
Web 技术基础与 Nginx 网站环境部署全解析
前端·nginx·microsoft
oo121383 小时前
里程碑4 - 基于Vue3完成动态组件库建设
前端
火车叼位3 小时前
告别表单“黄油色”:如何优雅地重置 Chrome 自动填充样式
前端
Dragon Wu3 小时前
Taro Webpack 5 编译过慢的解决方案
前端·webpack·小程序·taro
认真学GIS3 小时前
日尺度地下水水位!全国11897个地下水动态监测站点2005-2021年日尺度地下水水位(地下水埋深)(EXCEL格式)数据
服务器·前端·excel
_DoubleL3 小时前
Volta启动项目自动切换Node版本
前端·node.js
阿里巴巴终端技术3 小时前
[第 20 届 D2 倒计时] 7 大专场演讲、44 个精彩话题、D2 之夜畅聊 AI + 终端的发展前景
前端·人工智能·程序员