HeaderWriterFilter
定义与作用
HeaderWriterFilter 是 Spring Security 过滤器链中负责向响应写入安全 HTTP 头的过滤器。它位于 SecurityContextHolderFilter 之后、CorsFilter 之前,在每个响应返回给客户端之前,添加一组安全响应头,以防御常见的 Web 攻击(如 Clickjacking、MIME 类型嗅探、XSS 等)。这些头部由 HeaderWriter 接口实现生成,可灵活组合和自定义。
核心原理
1. HeaderWriter 接口与组合机制
public interface HeaderWriter {
// 向响应写入安全头部
void writeHeaders(HttpServletRequest request, HttpServletResponse response);
}
HeaderWriterFilter 内部维护 List<HeaderWriter>,响应提交前逐一调用:
// HeaderWriterFilter 核心逻辑(简化)
public class HeaderWriterFilter extends OncePerRequestFilter {
private final List<HeaderWriter> headerWriters;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
// 包装响应,在提交前拦截
HeaderWriterResponse wrappedResponse =
new HeaderWriterResponse(request, response, this.headerWriters);
try {
filterChain.doFilter(request, wrappedResponse);
} finally {
// 确保响应提交前写入头部(即使发生异常)
wrappedResponse.writeHeaders();
}
}
}
2. 内置安全响应头速查
| 响应头 | 默认值 | 防御目标 | 对应 HeaderWriter |
|---|---|---|---|
X-Frame-Options | DENY | Clickjacking(点击劫持) | XFrameOptionsHeaderWriter |
X-Content-Type-Options | nosniff | MIME 类型嗅探攻击 | XContentTypeOptionsHeaderWriter |
X-XSS-Protection | 0 | 禁用浏览器旧版 XSS 过滤器(避免其自身漏洞) | XXssProtectionHeaderWriter |
Strict-Transport-Security | max-age=31536000; includeSubDomains | HSTS 强制 HTTPS | HstsHeaderWriter |
Cache-Control | no-cache, no-store, max-age=0, must-revalidate | 防止缓存敏感信息 | CacheControlHeadersWriter |
Pragma | no-cache | HTTP/1.0 缓存控制 | CacheControlHeadersWriter |
Expires | 0 | 缓存过期 | CacheControlHeadersWriter |
3. 响应包装与延迟写入机制
完整示例一:自定义 CSP 策略与 Frame 选项
场景说明
应用需要:
- 允许同域 iframe 嵌套(如内部管理系统被其他同域页面嵌入),因此
X-Frame-Options不能是DENY,需要SAMEORIGIN或更精细的frame-ancestors。 - 配置 Content-Security-Policy (CSP) 限制脚本来源,防止 XSS 注入。
操作前配置(默认头部,无法满足需求)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.headers(Customizer.withDefaults()); // 默认 X-Frame-Options: DENY
return http.build();
}
问题分析:默认 X-Frame-Options: DENY 完全禁止任何 iframe 嵌套,包括同域页面。如果应用有合法的同域 iframe 使用场景(如管理后台被 dashboard 页面嵌入),会被浏览器拒绝加载。此外,默认未配置 CSP,无法限制脚本加载来源,XSS 防护不足。
操作后配置(精细化头部控制)
@Configuration
@EnableWebSecurity
public class CustomSecurityHeadersConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.headers(headers -> headers
// 1. 允许同域 iframe(SAMEORIGIN 替代 DENY)
.frameOptions(frame -> frame.sameOrigin())
// 2. 配置 Content-Security-Policy
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' 'nonce-{random}' cdn.example.com; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"frame-ancestors 'self'; " + // 替代 X-Frame-Options 的精细控制
"form-action 'self'; " +
"base-uri 'self';"
)
)
// 3. 强制 HTTPS(HSTS,仅生产环境启用)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1 年
// .preload(true) // 可选:提交到浏览器预加载列表
)
// 4. Referrer 策略(控制跳转时 Referer 头)
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
// 5. 保留默认的其他头部(X-Content-Type-Options, Cache-Control 等)
.defaultsDisabled() // 如果不需要,可禁用默认头部
// 此处不调用 defaultsDisabled(),保留默认 + 自定义
);
return http.build();
}
}
测试验证与结果分析
| 响应头 | 值 | 效果 |
|---|---|---|
X-Frame-Options | SAMEORIGIN | 允许同域页面 iframe 嵌入,阻止跨域嵌入 |
Content-Security-Policy | default-src 'self'; ... | 限制资源只能从同源加载,脚本仅允许同源 + 指定 CDN |
Strict-Transport-Security | max-age=31536000; includeSubDomains | 浏览器 1 年内强制使用 HTTPS 访问本域及子域 |
Referrer-Policy | strict-origin-when-cross-origin | 同域请求完整 Referrer,跨域请求仅发送 Origin |
X-Content-Type-Options | nosniff | 浏览器禁止 MIME 类型嗅探(默认保留) |
Cache-Control | no-cache, no-store... | 敏感页面不缓存(默认保留) |
# 使用 curl 验证响应头
curl -I -u user:password http://localhost:8080/home
# 观察响应中的 X-Frame-Options, Content-Security-Policy 等头
完整示例二:为不同路径配置差异化的安全头部
场景说明
应用同时提供:
- Web 管理页面(
/web/**):需要完整的 CSP、HSTS、X-Frame-Options 等头部。 - 图片/文件 API(
/files/**):需要放宽 CSP(允许图片加载),但保留 HSTS。 - 健康检查端点(
/health):无需缓存控制和复杂 CSP,但保留 HSTS。
操作前配置(统一头部,导致文件服务异常)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
);
return http.build();
}
问题分析:统一的 Content-Security-Policy 和 X-Frame-Options 应用到所有路径。/files/** 返回的图片如果需要通过 <img> 标签在其他页面加载,会被 CSP 限制;/health 端点返回 JSON 却携带 X-Frame-Options: DENY,无意义但可能干扰某些监控工具。
操作后配置(多链差异化头部)
@Configuration
@EnableWebSecurity
public class PathBasedHeadersConfig {
// 链1:Web 管理页面(完整安全头部)
@Bean
@Order(1)
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/web/**")
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"))
.formLogin(Customizer.withDefaults())
.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"frame-ancestors 'self';"
)
)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
// 保留默认 X-Content-Type-Options, Cache-Control
);
return http.build();
}
// 链2:文件服务(放宽 CSP,保留 HSTS)
@Bean
@Order(2)
public SecurityFilterChain fileSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/files/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/files/public/**").permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers
// 文件不需要 frame 控制(不是 HTML 页面)
.frameOptions(frame -> frame.disable())
// 文件服务放宽 CSP:允许图片和文件下载
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'none'; media-src 'self'")
)
// 保留 HSTS
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
)
// 文件可以缓存(与默认 Cache-Control 相反)
.cacheControl(cache -> cache.disable())
);
return http.build();
}
// 链3:健康检查(最小头部)
@Bean
@Order(3)
public SecurityFilterChain healthSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/health")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.headers(headers -> headers
// 仅保留 HSTS,其余禁用
.frameOptions(frame -> frame.disable())
.contentSecurityPolicy(csp -> csp.disable())
.cacheControl(cache -> cache.disable())
.xssProtection(xss -> xss.disable())
.contentTypeOptions(options -> options.disable())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
)
);
return http.build();
}
@Bean
@Order(4)
public SecurityFilterChain defaultSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.headers(Customizer.withDefaults()); // 默认头部
return http.build();
}
}
测试验证与结果分析
| 请求路径 | X-Frame-Options | CSP | Cache-Control | HSTS | 说明 |
|---|---|---|---|---|---|
/web/dashboard | SAMEORIGIN | 严格策略 | no-cache | 有 | 完整安全头部 |
/files/public/logo.png | 无 | default-src 'none' | 无(可缓存) | 有 | 放宽缓存和图片策略 |
/health | 无 | 无 | 无 | 有 | 最小头部,JSON 响应无干扰 |
/other/page | DENY(默认) | 默认 | no-cache | 无(默认配置) | 兜底链 |
易错场景:HeaderWriterFilter 位置导致头部被覆盖或丢失
问题现象
开发者自定义了一个 Filter 并在 HeaderWriterFilter 之后添加,该过滤器修改了响应状态码或调用了 response.reset()。导致 HeaderWriterFilter 写入的安全头部丢失。
错误配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.addFilterAfter(new CustomResponseFilter(), HeaderWriterFilter.class);
return http.build();
}
public class CustomResponseFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
chain.doFilter(request, response);
// 错误:在 HeaderWriterFilter 之后调用 response.reset()
// 这清除了所有已写入的响应头和响应体!
if (response.getStatus() == 500) {
response.reset();
response.setStatus(500);
response.getWriter().write("Internal Server Error");
}
}
}
问题分析:HeaderWriterFilter 的 HeaderWriterResponse 包装器在 response.getOutputStream().flush() 或 response.getWriter().flush() 时触发头部写入。如果后续过滤器调用 response.reset(),Servlet 容器会清除所有已设置的响应头(包括 HeaderWriterFilter 写入的安全头部),导致安全头丢失。
正确做法
- 不要在自定义过滤器中调用
response.reset(),除非确实需要清空所有内容,且意识到这会丢失安全头部。 - 如果必须在响应提交后修改状态,使用
@ControllerAdvice处理异常,而不是在过滤器中重置响应。 - 自定义过滤器如果需要包装响应,应在
HeaderWriterFilter之前执行,避免覆盖其写入的头部。
// 正确的异常处理:使用 @ControllerAdvice,在 HeaderWriterFilter 之后、Controller 之前
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
// 返回 ResponseEntity,HeaderWriterFilter 已在之前执行,头部保留
return ResponseEntity.status(500)
.body(new ErrorResponse("Internal Server Error", ex.getMessage()));
}
}
// 如果必须在过滤器中处理,避免 reset()
public class SafeResponseFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
// 包装响应以捕获状态码,但不 reset()
StatusCaptureResponseWrapper wrapper =
new StatusCaptureResponseWrapper(response);
chain.doFilter(request, wrapper);
// 基于状态码做日志记录,但不修改响应
if (wrapper.getStatus() == 500) {
log.error("请求 {} 返回 500", request.getRequestURI());
}
// 响应头和响应体保持原样,安全头部不受影响
}
}
面试考点:
HeaderWriterFilter使用响应包装器(HeaderWriterResponse)延迟写入头部,这种设计的目的是什么?如果某个后续过滤器调用了response.reset(),会导致什么问题? 答:HeaderWriterFilter使用HttpServletResponseWrapper包装响应的目的是延迟写入头部到响应真正提交时。因为响应头必须在响应体写入之前设置,如果HeaderWriterFilter在doFilter开始时立即写入头部,后续过滤器可能通过response.reset()或response.setHeader()覆盖或清除这些头部。延迟写入确保头部在最终响应提交前设置,且不会被后续过滤器的常规操作覆盖。但如果后续过滤器显式调用response.reset(),Servlet 规范要求清除所有已设置的响应头(包括状态码、头部和缓冲区),这会导致HeaderWriterFilter写入的安全头部全部丢失。因此应避免在过滤器链中调用response.reset(),或确保此类操作在HeaderWriterFilter之前执行。