Skip to content

Commit 501ebef

Browse files
feat(infra): add security response headers filter to platform-infra (STA-225) (#235)
Add SecurityHeadersFilter as a jakarta.servlet.Filter in platform-infra so all services automatically include security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Cache-Control, Referrer-Policy, Permissions-Policy) on every HTTP response. Configurable via SecurityHeadersProperties: - app.security.headers.enabled (default true) - app.security.headers.hsts-enabled (default true, disable for HTTP dev) - app.security.headers.hsts-max-age (default 31536000) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fa0d055 commit 501ebef

3 files changed

Lines changed: 224 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.stablecoin.payments.platform.infrastructure.security;
2+
3+
import jakarta.servlet.Filter;
4+
import jakarta.servlet.FilterChain;
5+
import jakarta.servlet.ServletException;
6+
import jakarta.servlet.ServletRequest;
7+
import jakarta.servlet.ServletResponse;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
10+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
11+
import org.springframework.core.Ordered;
12+
import org.springframework.core.annotation.Order;
13+
import org.springframework.stereotype.Component;
14+
15+
import java.io.IOException;
16+
17+
@Component
18+
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
19+
@ConditionalOnProperty(name = "app.security.headers.enabled", havingValue = "true", matchIfMissing = true)
20+
@EnableConfigurationProperties(SecurityHeadersProperties.class)
21+
public class SecurityHeadersFilter implements Filter {
22+
23+
static final String HEADER_X_CONTENT_TYPE_OPTIONS = "X-Content-Type-Options";
24+
static final String HEADER_X_FRAME_OPTIONS = "X-Frame-Options";
25+
static final String HEADER_CONTENT_SECURITY_POLICY = "Content-Security-Policy";
26+
static final String HEADER_X_XSS_PROTECTION = "X-XSS-Protection";
27+
static final String HEADER_CACHE_CONTROL = "Cache-Control";
28+
static final String HEADER_PRAGMA = "Pragma";
29+
static final String HEADER_REFERRER_POLICY = "Referrer-Policy";
30+
static final String HEADER_PERMISSIONS_POLICY = "Permissions-Policy";
31+
static final String HEADER_STRICT_TRANSPORT_SECURITY = "Strict-Transport-Security";
32+
33+
private final SecurityHeadersProperties properties;
34+
35+
public SecurityHeadersFilter(SecurityHeadersProperties properties) {
36+
this.properties = properties;
37+
}
38+
39+
@Override
40+
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
41+
throws IOException, ServletException {
42+
43+
if (response instanceof HttpServletResponse httpResponse) {
44+
httpResponse.setHeader(HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff");
45+
httpResponse.setHeader(HEADER_X_FRAME_OPTIONS, "DENY");
46+
httpResponse.setHeader(HEADER_CONTENT_SECURITY_POLICY, "default-src 'none'");
47+
httpResponse.setHeader(HEADER_X_XSS_PROTECTION, "0");
48+
httpResponse.setHeader(HEADER_CACHE_CONTROL, "no-store");
49+
httpResponse.setHeader(HEADER_PRAGMA, "no-cache");
50+
httpResponse.setHeader(HEADER_REFERRER_POLICY, "strict-origin-when-cross-origin");
51+
httpResponse.setHeader(HEADER_PERMISSIONS_POLICY, "camera=(), microphone=(), geolocation=()");
52+
53+
if (properties.hstsEnabled()) {
54+
httpResponse.setHeader(HEADER_STRICT_TRANSPORT_SECURITY,
55+
"max-age=" + properties.hstsMaxAge() + "; includeSubDomains");
56+
}
57+
}
58+
59+
chain.doFilter(request, response);
60+
}
61+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.stablecoin.payments.platform.infrastructure.security;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
@ConfigurationProperties(prefix = "app.security.headers")
6+
public record SecurityHeadersProperties(
7+
Boolean enabled,
8+
Boolean hstsEnabled,
9+
Long hstsMaxAge
10+
) {
11+
12+
private static final boolean DEFAULT_ENABLED = true;
13+
private static final boolean DEFAULT_HSTS_ENABLED = true;
14+
private static final long DEFAULT_HSTS_MAX_AGE = 31_536_000L;
15+
16+
public SecurityHeadersProperties {
17+
if (enabled == null) {
18+
enabled = DEFAULT_ENABLED;
19+
}
20+
if (hstsEnabled == null) {
21+
hstsEnabled = DEFAULT_HSTS_ENABLED;
22+
}
23+
if (hstsMaxAge == null) {
24+
hstsMaxAge = DEFAULT_HSTS_MAX_AGE;
25+
}
26+
}
27+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.stablecoin.payments.platform.infrastructure.security;
2+
3+
import jakarta.servlet.ServletException;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.mock.web.MockFilterChain;
7+
import org.springframework.mock.web.MockHttpServletRequest;
8+
import org.springframework.mock.web.MockHttpServletResponse;
9+
10+
import java.io.IOException;
11+
12+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_CACHE_CONTROL;
13+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_CONTENT_SECURITY_POLICY;
14+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_PERMISSIONS_POLICY;
15+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_PRAGMA;
16+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_REFERRER_POLICY;
17+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_STRICT_TRANSPORT_SECURITY;
18+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_X_CONTENT_TYPE_OPTIONS;
19+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_X_FRAME_OPTIONS;
20+
import static com.stablecoin.payments.platform.infrastructure.security.SecurityHeadersFilter.HEADER_X_XSS_PROTECTION;
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
23+
@DisplayName("SecurityHeadersFilter")
24+
class SecurityHeadersFilterTest {
25+
26+
@Test
27+
@DisplayName("should add all security headers when enabled with defaults")
28+
void shouldAddAllSecurityHeadersWhenEnabledWithDefaults() throws ServletException, IOException {
29+
// given
30+
var properties = new SecurityHeadersProperties(true, true, 31_536_000L);
31+
var filter = new SecurityHeadersFilter(properties);
32+
var request = new MockHttpServletRequest();
33+
var response = new MockHttpServletResponse();
34+
var filterChain = new MockFilterChain();
35+
36+
// when
37+
filter.doFilter(request, response, filterChain);
38+
39+
// then
40+
assertThat(response.getHeaderNames())
41+
.containsExactlyInAnyOrder(
42+
HEADER_X_CONTENT_TYPE_OPTIONS,
43+
HEADER_X_FRAME_OPTIONS,
44+
HEADER_CONTENT_SECURITY_POLICY,
45+
HEADER_X_XSS_PROTECTION,
46+
HEADER_CACHE_CONTROL,
47+
HEADER_PRAGMA,
48+
HEADER_REFERRER_POLICY,
49+
HEADER_PERMISSIONS_POLICY,
50+
HEADER_STRICT_TRANSPORT_SECURITY
51+
);
52+
53+
assertThat(response.getHeader(HEADER_X_CONTENT_TYPE_OPTIONS)).isEqualTo("nosniff");
54+
assertThat(response.getHeader(HEADER_X_FRAME_OPTIONS)).isEqualTo("DENY");
55+
assertThat(response.getHeader(HEADER_CONTENT_SECURITY_POLICY)).isEqualTo("default-src 'none'");
56+
assertThat(response.getHeader(HEADER_X_XSS_PROTECTION)).isEqualTo("0");
57+
assertThat(response.getHeader(HEADER_CACHE_CONTROL)).isEqualTo("no-store");
58+
assertThat(response.getHeader(HEADER_PRAGMA)).isEqualTo("no-cache");
59+
assertThat(response.getHeader(HEADER_REFERRER_POLICY)).isEqualTo("strict-origin-when-cross-origin");
60+
assertThat(response.getHeader(HEADER_PERMISSIONS_POLICY)).isEqualTo("camera=(), microphone=(), geolocation=()");
61+
assertThat(response.getHeader(HEADER_STRICT_TRANSPORT_SECURITY)).isEqualTo("max-age=31536000; includeSubDomains");
62+
}
63+
64+
@Test
65+
@DisplayName("should not add HSTS header when hsts-enabled is false")
66+
void shouldNotAddHstsHeaderWhenHstsDisabled() throws ServletException, IOException {
67+
// given
68+
var properties = new SecurityHeadersProperties(true, false, 31_536_000L);
69+
var filter = new SecurityHeadersFilter(properties);
70+
var request = new MockHttpServletRequest();
71+
var response = new MockHttpServletResponse();
72+
var filterChain = new MockFilterChain();
73+
74+
// when
75+
filter.doFilter(request, response, filterChain);
76+
77+
// then
78+
assertThat(response.getHeaderNames()).doesNotContain(HEADER_STRICT_TRANSPORT_SECURITY);
79+
assertThat(response.getHeader(HEADER_X_CONTENT_TYPE_OPTIONS)).isEqualTo("nosniff");
80+
assertThat(response.getHeader(HEADER_X_FRAME_OPTIONS)).isEqualTo("DENY");
81+
}
82+
83+
@Test
84+
@DisplayName("should use custom HSTS max-age when configured")
85+
void shouldUseCustomHstsMaxAge() throws ServletException, IOException {
86+
// given
87+
var customMaxAge = 86_400L;
88+
var properties = new SecurityHeadersProperties(true, true, customMaxAge);
89+
var filter = new SecurityHeadersFilter(properties);
90+
var request = new MockHttpServletRequest();
91+
var response = new MockHttpServletResponse();
92+
var filterChain = new MockFilterChain();
93+
94+
// when
95+
filter.doFilter(request, response, filterChain);
96+
97+
// then
98+
assertThat(response.getHeader(HEADER_STRICT_TRANSPORT_SECURITY)).isEqualTo("max-age=86400; includeSubDomains");
99+
}
100+
101+
@Test
102+
@DisplayName("should continue filter chain after adding headers")
103+
void shouldContinueFilterChainAfterAddingHeaders() throws ServletException, IOException {
104+
// given
105+
var properties = new SecurityHeadersProperties(true, true, 31_536_000L);
106+
var filter = new SecurityHeadersFilter(properties);
107+
var request = new MockHttpServletRequest();
108+
var response = new MockHttpServletResponse();
109+
var filterChain = new MockFilterChain();
110+
111+
// when
112+
filter.doFilter(request, response, filterChain);
113+
114+
// then
115+
assertThat(filterChain.getRequest()).isEqualTo(request);
116+
assertThat(filterChain.getResponse()).isEqualTo(response);
117+
}
118+
119+
@Test
120+
@DisplayName("should apply default property values when nulls provided")
121+
void shouldApplyDefaultPropertyValuesWhenNullsProvided() throws ServletException, IOException {
122+
// given
123+
var properties = new SecurityHeadersProperties(null, null, null);
124+
var filter = new SecurityHeadersFilter(properties);
125+
var request = new MockHttpServletRequest();
126+
var response = new MockHttpServletResponse();
127+
var filterChain = new MockFilterChain();
128+
129+
// when
130+
filter.doFilter(request, response, filterChain);
131+
132+
// then
133+
assertThat(response.getHeader(HEADER_STRICT_TRANSPORT_SECURITY)).isEqualTo("max-age=31536000; includeSubDomains");
134+
assertThat(response.getHeader(HEADER_X_CONTENT_TYPE_OPTIONS)).isEqualTo("nosniff");
135+
}
136+
}

0 commit comments

Comments
 (0)