From 381dc3884b22f8ec9d9d451994d48389db03f3d9 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Sun, 11 Jan 2026 15:36:45 +0800 Subject: [PATCH 01/26] Fix method chaining for ContentSecurityPolicySpec Signed-off-by: Ziqin Wang --- .../config/web/server/ServerHttpSecurity.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 6dff6f207e7..00ceeaa0d75 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2845,21 +2845,21 @@ private ContentSecurityPolicySpec() { * in the response. Otherwise, defaults to the {@code Content-Security-Policy} * header. * @param reportOnly whether to only report policy violations - * @return the {@link HeaderSpec} to continue configuring + * @return the {@link ContentSecurityPolicySpec} to continue configuring */ - public HeaderSpec reportOnly(boolean reportOnly) { + public ContentSecurityPolicySpec reportOnly(boolean reportOnly) { HeaderSpec.this.contentSecurityPolicy.setReportOnly(reportOnly); - return HeaderSpec.this; + return this; } /** * Sets the security policy directive(s) to be used in the response header. * @param policyDirectives the security policy directive(s) - * @return the {@link HeaderSpec} to continue configuring + * @return the {@link ContentSecurityPolicySpec} to continue configuring */ - public HeaderSpec policyDirectives(String policyDirectives) { + public ContentSecurityPolicySpec policyDirectives(String policyDirectives) { HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); - return HeaderSpec.this; + return this; } private ContentSecurityPolicySpec(String policyDirectives) { From a91d888b76588ee2f099373059ffe620af732928 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Wed, 14 Jan 2026 22:42:47 +0800 Subject: [PATCH 02/26] Support nonce-based Content-Security-Policy When strict Content Security Policy is used, web browsers block inline + Default + + """.formatted(cspNonce); + } + + @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + String custom(@RequestAttribute("CUSTOM_NONCE") String cspNonce) { + return """ + + + + Custom + + """.formatted(cspNonce); + } + + } + + @Configuration + @EnableWebSecurity + static class ContentSecurityPolicyDefaultNonceConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) { + // @formatter:off + http + .headers((headers) -> headers + .defaultsDisabled() + .contentSecurityPolicy((csp) -> csp + .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ContentSecurityPolicyCustomNonceConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) { + // @formatter:off + http + .headers((headers) -> headers + .defaultsDisabled() + .contentSecurityPolicy((csp) -> csp + .policyDirectives("script-src 'self' 'nonce-{nonce}'") + .nonceAttributeName("CUSTOM_NONCE"))); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ContentSecurityPolicyMatcherConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) { + // @formatter:off + http + .headers((headers) -> headers + .defaultsDisabled() + .contentSecurityPolicy((csp) -> csp + .policyDirectives("default-src 'self'") + .requireCspMatcher((request) -> { + var accepted = MediaType.parseMediaTypes(request.getHeader(HttpHeaders.ACCEPT)); + return MediaType.TEXT_HTML.isPresentIn(accepted); + }))); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ContentSecurityPolicyPathMatchersConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) { + // @formatter:off + http + .headers((headers) -> headers + .defaultsDisabled() + .contentSecurityPolicy((csp) -> csp + .policyDirectives("default-src 'self'") + .requireCspMatchers("/foo/**", "/bar/**"))); + // @formatter:on + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + static class ContentSecurityPolicyOverriddenMatchersConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) { + // @formatter:off + http + .headers((headers) -> headers + .defaultsDisabled() + .contentSecurityPolicy((csp) -> csp + .policyDirectives("default-src 'self'") + .requireCspMatcher(AnyRequestMatcher.INSTANCE) + .requireCspMatchers("/**"))); + // @formatter:on + return http.build(); + } + + } + @Configuration @EnableWebSecurity static class ReferrerPolicyDefaultConfig { diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index 7fe1052f323..f79ee552238 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -19,12 +19,14 @@ import java.time.Duration; import java.util.HashSet; import java.util.Set; +import java.util.regex.Pattern; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter; @@ -38,10 +40,18 @@ import org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter; import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter; import org.springframework.security.web.server.header.XXssProtectionServerHttpHeadersWriter; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.stereotype.Controller; import org.springframework.test.web.reactive.server.FluxExchangeResult; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.assertj.WebTestClientResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.ResponseBody; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.springframework.security.config.Customizer.withDefaults; /** @@ -51,6 +61,7 @@ * @author Vedran Pavic * @author Ankur Pathak * @author Marcus Da Coregio + * @author Ziqin Wang * @since 5.0 */ public class HeaderSpecTests { @@ -390,6 +401,135 @@ public void headersWhenContentSecurityPolicyEnabledInLambdaThenContentSecurityPo assertHeaders(); } + /** @since 7.1 */ + @Test + public void headersWhenContentSecurityPolicyEnabledWithDefaultNonceThenHeaderMatchesContent() { + String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; + Pattern regex = Pattern.compile("^script-src 'self' 'nonce-([A-Za-z0-9+/]{22,}={0,2})'$"); + + // @formatter:off + this.http.headers((headers) -> headers + .contentSecurityPolicy((csp) -> csp + .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); + // @formatter:on + WebTestClient client = WebTestClientBuilder + .bindToControllerAndWebFilters(ReactiveTestCspNonceController.class, this.http.build()) + .build(); + + WebTestClient.ResponseSpec spec = client.get().uri("https://example.com/").exchange(); + WebTestClientResponse response = WebTestClientResponse.from(spec); + + assertThat(response).hasContentTypeCompatibleWith(MediaType.TEXT_HTML); + assertThat(response).headers().hasHeaderSatisfying(headerName, (cspList) -> { + assertThat(cspList).singleElement().asString().matchesSatisfying(regex, (matcher) -> { + String nonce = matcher.group(1); + assertThat(nonce).isNotNull(); + assertThat(response).bodyText().contains("nonce=\"" + nonce + "\""); + }); + }); + } + + /** @since 7.1 */ + @Test + public void headersWhenContentSecurityPolicyEnabledWithCustomNonceThenHeaderMatchesContent() { + String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; + Pattern regex = Pattern.compile("^script-src 'self' 'nonce-([A-Za-z0-9+/]{22,}={0,2})'$"); + + // @formatter:off + this.http.headers((headers) -> headers + .contentSecurityPolicy((csp) -> csp + .nonceAttributeName("CUSTOM_NONCE") + .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); + // @formatter:on + WebTestClient client = WebTestClientBuilder + .bindToControllerAndWebFilters(ReactiveTestCspNonceController.class, this.http.build()) + .build(); + + WebTestClient.ResponseSpec spec = client.get().uri("https://example.com/custom").exchange(); + WebTestClientResponse response = WebTestClientResponse.from(spec); + + assertThat(response).hasContentTypeCompatibleWith(MediaType.TEXT_HTML); + assertThat(response).headers().hasHeaderSatisfying(headerName, (cspList) -> { + assertThat(cspList).singleElement().asString().matchesSatisfying(regex, (matcher) -> { + String nonce = matcher.group(1); + assertThat(nonce).isNotNull(); + assertThat(response).bodyText().contains("nonce=\"" + nonce + "\""); + }); + }); + } + + /** @since 7.1 */ + @Test + public void headersWhenContentSecurityPolicyEnabledWithMatcherThenHeaderInResponseIfMatched() { + String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; + String policyDirectives = "default-src 'self'"; + // @formatter:off + this.http.headers((headers) -> headers + .contentSecurityPolicy((csp) -> csp + .requireCspMatcher((exchange) -> + (MediaType.TEXT_HTML.isPresentIn(exchange.getRequest().getHeaders().getAccept()) ? + ServerWebExchangeMatcher.MatchResult.match() : + ServerWebExchangeMatcher.MatchResult.notMatch())) + .policyDirectives(policyDirectives))); + // @formatter:on + WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); + // @formatter:off + client.get() + .uri("https://example.com/") + .accept(MediaType.TEXT_HTML) + .exchange() + .expectHeader().valueEquals(headerName, policyDirectives); + client.get() + .uri("https://example.com/") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectHeader().doesNotExist(headerName); + // @formatter:on + } + + /** @since 7.1 */ + @Test + public void headersWhenContentSecurityPolicyEnabledWithPathMatchersThenHeaderInResponseIfMatched() { + String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; + String policyDirectives = "default-src 'self'"; + // @formatter:off + this.http.headers((headers) -> headers + .contentSecurityPolicy((csp) -> csp + .requireCspMatchers("/foo/**", "/bar/**") + .policyDirectives(policyDirectives))); + // @formatter:on + WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); + // @formatter:off + client.get() + .uri("https://example.com/foo/bar") + .exchange() + .expectHeader().valueEquals(headerName, policyDirectives); + client.get() + .uri("https://example.com/bar/foo") + .exchange() + .expectHeader().valueEquals(headerName, policyDirectives); + client.get() + .uri("https://example.com/foobar") + .exchange() + .expectHeader().doesNotExist(headerName); + // @formatter:on + } + + /** @since 7.1 */ + @Test + public void headersWhenContentSecurityPolicyWithOverriddenMatchersThenFailToConfigure() { + // @formatter:off + assertThatIllegalStateException() + .isThrownBy(() -> this.http + .headers((headers) -> headers + .contentSecurityPolicy((csp) -> csp + .requireCspMatcher(ServerWebExchangeMatchers.anyExchange()) + .requireCspMatchers("/**") + .policyDirectives("default-src 'self'")))) + .withMessage("RequireCspMatcher(s) is already configured"); + // @formatter:on + } + @Test public void headersWhenReferrerPolicyEnabledThenFeaturePolicyWritten() { this.expectedHeaders.add(ReferrerPolicyServerHttpHeadersWriter.REFERRER_POLICY, @@ -525,4 +665,33 @@ private WebTestClient buildClient() { return WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); } + @Controller + static class ReactiveTestCspNonceController { + + @GetMapping(produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + Mono defaultAttribute(@RequestAttribute("_csp_nonce") String cspNonce) { + return Mono.fromSupplier(() -> """ + + + + Default + + """.formatted(cspNonce)); + } + + @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + Mono custom(@RequestAttribute("CUSTOM_NONCE") String cspNonce) { + return Mono.fromSupplier(() -> """ + + + + Custom + + """.formatted(cspNonce)); + } + + } + } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt index ce98f9d1cf4..4d6610b1192 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt @@ -16,18 +16,24 @@ package org.springframework.security.config.annotation.web.headers +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.hamcrest.Matchers.matchesPattern import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.invoke import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter +import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter +import org.springframework.security.web.util.matcher.AnyRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -35,6 +41,7 @@ import org.springframework.test.web.servlet.get * Tests for [ContentSecurityPolicyDsl] * * @author Eleftheria Stein + * @author Ziqin Wang */ @ExtendWith(SpringTestContextExtension::class) class ContentSecurityPolicyDslTests { @@ -51,7 +58,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, "default-src 'self'") } + header { string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, "default-src 'self'") } } } @@ -77,7 +84,10 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, "default-src 'self'; script-src trustedscripts.example.com") } + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, + "default-src 'self'; script-src trustedscripts.example.com") + } } } @@ -105,7 +115,10 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY_REPORT_ONLY, "default-src 'self'") } + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER, + "default-src 'self'") + } } } @@ -125,4 +138,187 @@ class ContentSecurityPolicyDslTests { return http.build() } } + + /** @since 7.1 */ + @Test + fun `headers when content security policy configured with default nonce attribute then header in response`() { + this.spring.register(ContentSecurityPolicyDefaultNonceConfig::class.java).autowire() + + this.mockMvc.get("/") { + secure = true + }.andExpect { + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, + matchesPattern("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$")) + } + } + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyDefaultNonceConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "script-src 'self' 'nonce-{nonce}'" + } + } + } + return http.build() + } + } + + /** @since 7.1 */ + @Test + fun `headers when content security policy configured with custom nonce attribute then header in response`() { + this.spring.register(ContentSecurityPolicyCustomNonceConfig::class.java).autowire() + + this.mockMvc.get("/") { + secure = true + }.andExpect { + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, + matchesPattern("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$")) + } + } + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyCustomNonceConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "script-src 'self' 'nonce-{nonce}'" + nonceAttributeName = "CUSTOM_NONCE" + } + } + } + return http.build() + } + } + + /** @since 7.1 */ + @Test + fun `headers when content security policy configured with matcher then header in response if matched`() { + this.spring.register(ContentSecurityPolicyMatcherConfig::class.java).autowire() + + this.mockMvc.get("/") { + secure = true + accept = MediaType.TEXT_HTML + }.andExpect { + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, + "default-src 'self'") + } + } + this.mockMvc.get("/") { + secure = true + accept = MediaType.TEXT_PLAIN + }.andExpect { + header { doesNotExist(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER) } + } + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyMatcherConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requireCspMatcher = RequestMatcher { request -> + val accepted = MediaType.parseMediaTypes(request.getHeader(HttpHeaders.ACCEPT)) + MediaType.TEXT_HTML.isPresentIn(accepted) + } + } + } + } + return http.build() + } + } + + /** @since 7.1 */ + @Test + fun `headers when content security policy configured with path matchers then header in response if matched`() { + this.spring.register(ContentSecurityPolicyPathMatchersConfig::class.java).autowire() + + this.mockMvc.get("/foo/bar") { + secure = true + }.andExpect { + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, + "default-src 'self'") + } + } + this.mockMvc.get("/bar/foo") { + secure = true + }.andExpect { + header { + string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, + "default-src 'self'") + } + } + this.mockMvc.get("/foobar") { + secure = true + }.andExpect { + header { doesNotExist(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER) } + } + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyPathMatchersConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requireCspMatchers("/foo/**", "/bar/**") + } + } + } + return http.build() + } + } + + /** @since 7.1 */ + @Test + fun `headers when content security policy configured with overridden matchers then throws`() { + assertThatThrownBy { + this.spring.register(ContentSecurityPolicyOverriddenMatchersConfig::class.java).autowire() + }.hasRootCauseInstanceOf(IllegalStateException::class.java) + .hasRootCauseMessage("RequireCspMatcher(s) is already configured") + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyOverriddenMatchersConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requireCspMatcher = AnyRequestMatcher.INSTANCE + requireCspMatchers("/**") + } + } + } + return http.build() + } + } + } diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt index 536d5b3e129..91f96950f08 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt @@ -16,17 +16,21 @@ package org.springframework.security.config.web.server +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.config.EnableWebFlux @@ -34,6 +38,7 @@ import org.springframework.web.reactive.config.EnableWebFlux * Tests for [ServerContentSecurityPolicyDsl] * * @author Eleftheria Stein + * @author Ziqin Wang */ @ExtendWith(SpringTestContextExtension::class) class ServerContentSecurityPolicyDslTests { @@ -125,4 +130,160 @@ class ServerContentSecurityPolicyDslTests { } } } + + /** @since 7.1 */ + @Test + fun `request when configured with default nonce then CSP header with nonce in response`() { + this.spring.register(CspDefaultNonceConfig::class.java).autowire() + + this.client.get() + .uri("https://example.com/") + .exchange() + .expectHeader().valueMatches(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, + "^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$") + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class CspDefaultNonceConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + headers { + contentSecurityPolicy { + policyDirectives = "script-src 'self' 'nonce-{nonce}'" + } + } + } + } + } + + /** @since 7.1 */ + @Test + fun `request when configured with custom nonce then CSP header with nonce in response`() { + this.spring.register(CspCustomNonceConfig::class.java).autowire() + + this.client.get() + .uri("https://example.com/") + .exchange() + .expectHeader().valueMatches(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, + "^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$") + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class CspCustomNonceConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + headers { + contentSecurityPolicy { + policyDirectives = "script-src 'self' 'nonce-{nonce}'" + nonceAttributeName = "CUSTOM_NONCE" + } + } + } + } + } + + /** @since 7.1 */ + @Test + fun `request when configured with matcher then CSP header in response if matched`() { + val headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY + this.spring.register(CspMatcherConfig::class.java).autowire() + + this.client.get() + .uri("https://example.com/") + .accept(MediaType.TEXT_HTML) + .exchange() + .expectHeader().valueEquals(headerName, "default-src 'self'") + this.client.get() + .uri("https://example.com/") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectHeader().doesNotExist(headerName) + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class CspMatcherConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http { + headers { + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requireCspMatcher = ServerWebExchangeMatcher { exchange -> + if (MediaType.TEXT_HTML.isPresentIn(exchange.request.headers.accept)) + ServerWebExchangeMatcher.MatchResult.match() + else + ServerWebExchangeMatcher.MatchResult.notMatch() + } + } + } + } + } + + /** @since 7.1 */ + @Test + fun `request when configured with path matchers then CSP header in response if matched`() { + val headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY + this.spring.register(CspPathMatchersConfig::class.java).autowire() + + this.client.get() + .uri("https://example.com/foo/bar") + .exchange() + .expectHeader().valueEquals(headerName, "default-src 'self'") + this.client.get() + .uri("https://example.com/bar/foo") + .exchange() + .expectHeader().valueEquals(headerName, "default-src 'self'") + this.client.get() + .uri("https://example.com/foobar") + .exchange() + .expectHeader().doesNotExist(headerName) + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class CspPathMatchersConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http { + headers { + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requireCspMatchers("/foo/**", "/bar/**") + } + } + } + } + + /** @since 7.1 */ + @Test + fun `when matchers overridden then fails to configure`() { + assertThatThrownBy { + this.spring.register(CspPathOverriddenMatchersConfig::class.java).autowire() + }.hasRootCauseInstanceOf(IllegalStateException::class.java) + .hasRootCauseMessage("RequireCspMatcher(s) is already configured") + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class CspPathOverriddenMatchersConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http { + headers { + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requireCspMatcher = ServerWebExchangeMatchers.anyExchange() + requireCspMatchers("/**") + } + } + } + } + } From 1cec46598241c62d7cbd53e1ca34d3fc440a97e1 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Wed, 1 Apr 2026 12:43:57 +0800 Subject: [PATCH 04/26] Revert "Fix method chaining for ContentSecurityPolicySpec" This reverts commit 381dc3884b22f8ec9d9d451994d48389db03f3d9. Signed-off-by: Ziqin Wang --- .../config/web/server/ServerHttpSecurity.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 60b93aa38d6..292742de53d 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2852,11 +2852,11 @@ private ContentSecurityPolicySpec() { * in the response. Otherwise, defaults to the {@code Content-Security-Policy} * header. * @param reportOnly whether to only report policy violations - * @return the {@link ContentSecurityPolicySpec} to continue configuring + * @return the {@link HeaderSpec} to continue configuring */ - public ContentSecurityPolicySpec reportOnly(boolean reportOnly) { + public HeaderSpec reportOnly(boolean reportOnly) { HeaderSpec.this.contentSecurityPolicy.setReportOnly(reportOnly); - return this; + return HeaderSpec.this; } /** @@ -2865,11 +2865,11 @@ public ContentSecurityPolicySpec reportOnly(boolean reportOnly) { * for a generated secure random nonce, e.g., {@code script-src 'self' * 'nonce-{nonce}'}. * @param policyDirectives the security policy directive(s) - * @return the {@link ContentSecurityPolicySpec} to continue configuring + * @return the {@link HeaderSpec} to continue configuring */ - public ContentSecurityPolicySpec policyDirectives(String policyDirectives) { + public HeaderSpec policyDirectives(String policyDirectives) { HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); - return this; + return HeaderSpec.this; } /** From 66c02422c58ba8ffa618ee087053a58e028e0f2b Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Tue, 31 Mar 2026 13:48:31 +0800 Subject: [PATCH 05/26] Make nonce-generating filters ad hoc instead of general-purpose Signed-off-by: Ziqin Wang --- .../web/configurers/HeadersConfigurer.java | 5 +++-- .../config/web/server/ServerHttpSecurity.java | 6 ++++-- ...tSecurityPolicyNonceGeneratingFilter.java} | 12 +++++------- .../ContentSecurityPolicyHeaderWriter.java | 14 ++++++++------ ...curityPolicyNonceGeneratingWebFilter.java} | 12 +++++------- ...SecurityPolicyServerHttpHeadersWriter.java | 10 ++++------ ...rityPolicyNonceGeneratingFilterTests.java} | 19 +++++++++++-------- ...yPolicyNonceGeneratingWebFilterTests.java} | 18 ++++++++++-------- 8 files changed, 50 insertions(+), 46 deletions(-) rename web/src/main/java/org/springframework/security/web/header/{NonceGeneratingFilter.java => ContentSecurityPolicyNonceGeneratingFilter.java} (88%) rename web/src/main/java/org/springframework/security/web/server/header/{NonceGeneratingWebFilter.java => ContentSecurityPolicyNonceGeneratingWebFilter.java} (87%) rename web/src/test/java/org/springframework/security/web/header/{NonceGeneratingFilterTests.java => ContentSecurityPolicyNonceGeneratingFilterTests.java} (78%) rename web/src/test/java/org/springframework/security/web/server/header/{NonceGeneratingWebFilterTests.java => ContentSecurityPolicyNonceGeneratingWebFilterTests.java} (79%) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java index 4119f8f7179..904f7b81106 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java @@ -30,9 +30,9 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; import org.springframework.security.web.header.HeaderWriter; import org.springframework.security.web.header.HeaderWriterFilter; -import org.springframework.security.web.header.NonceGeneratingFilter; import org.springframework.security.web.header.writers.CacheControlHeadersWriter; import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter; import org.springframework.security.web.header.writers.CrossOriginEmbedderPolicyHeaderWriter; @@ -286,7 +286,8 @@ public void configure(H http) { private void configureCspNonceGeneratingFilter(H http) { ContentSecurityPolicyHeaderWriter writer = this.contentSecurityPolicy.writer; if (writer != null && writer.isNonceBased()) { - http.addFilterBefore(new NonceGeneratingFilter(writer.getNonceAttributeName()), HeaderWriterFilter.class); + http.addFilterBefore(new ContentSecurityPolicyNonceGeneratingFilter(writer.getNonceAttributeName()), + HeaderWriterFilter.class); } } diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 292742de53d..250ad0cfdb5 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -183,6 +183,7 @@ import org.springframework.security.web.server.csrf.WebSessionServerCsrfTokenRepository; import org.springframework.security.web.server.header.CacheControlServerHttpHeadersWriter; import org.springframework.security.web.server.header.CompositeServerHttpHeadersWriter; +import org.springframework.security.web.server.header.ContentSecurityPolicyNonceGeneratingWebFilter; import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ContentTypeOptionsServerHttpHeadersWriter; import org.springframework.security.web.server.header.CrossOriginEmbedderPolicyServerHttpHeadersWriter; @@ -193,7 +194,6 @@ import org.springframework.security.web.server.header.CrossOriginResourcePolicyServerHttpHeadersWriter.CrossOriginResourcePolicy; import org.springframework.security.web.server.header.FeaturePolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.HttpHeaderWriterWebFilter; -import org.springframework.security.web.server.header.NonceGeneratingWebFilter; import org.springframework.security.web.server.header.PermissionsPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy; @@ -2564,7 +2564,9 @@ protected void configure(ServerHttpSecurity http) { HttpHeaderWriterWebFilter result = new HttpHeaderWriterWebFilter(writer); http.addFilterAt(result, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); if (this.contentSecurityPolicy.isNonceBased()) { - http.addFilterBefore(new NonceGeneratingWebFilter(this.contentSecurityPolicy.getNonceAttributeName()), + http.addFilterBefore( + new ContentSecurityPolicyNonceGeneratingWebFilter( + this.contentSecurityPolicy.getNonceAttributeName()), SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); } } diff --git a/web/src/main/java/org/springframework/security/web/header/NonceGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java similarity index 88% rename from web/src/main/java/org/springframework/security/web/header/NonceGeneratingFilter.java rename to web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java index b59517b170f..8567ab0fca5 100644 --- a/web/src/main/java/org/springframework/security/web/header/NonceGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -30,7 +30,8 @@ import org.springframework.web.filter.OncePerRequestFilter; /** - * A filter which generates a nonce string and sets it as a request attribute. + * A filter which generates a nonce string for Content Security Policy and sets it as a + * request attribute. * *

* {@link org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter} @@ -38,13 +39,10 @@ * technology can render the nonce in generated HTML to allow intended inline * {@code Default - """.formatted(cspNonce); + """.formatted(cspNonce.get()); } @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - String custom(@RequestAttribute("CUSTOM_NONCE") String cspNonce) { + String custom(@RequestAttribute("CUSTOM_NONCE") Supplier cspNonce) { return """ Custom - """.formatted(cspNonce); + """.formatted(cspNonce.get()); } } diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index f79ee552238..fec1e22ff63 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -670,26 +670,26 @@ static class ReactiveTestCspNonceController { @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - Mono defaultAttribute(@RequestAttribute("_csp_nonce") String cspNonce) { - return Mono.fromSupplier(() -> """ + Mono defaultAttribute(@RequestAttribute("_csp_nonce") Mono cspNonce) { + return cspNonce.map(""" Default - """.formatted(cspNonce)); + """::formatted); } @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - Mono custom(@RequestAttribute("CUSTOM_NONCE") String cspNonce) { - return Mono.fromSupplier(() -> """ + Mono custom(@RequestAttribute("CUSTOM_NONCE") Mono cspNonce) { + return cspNonce.map(""" Custom - """.formatted(cspNonce)); + """::formatted); } } diff --git a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java index 8567ab0fca5..3f94b59bbf5 100644 --- a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.util.Base64; +import java.util.function.Supplier; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -27,6 +28,7 @@ import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; import org.springframework.security.crypto.keygen.StringKeyGenerator; import org.springframework.util.Assert; +import org.springframework.util.function.SingletonSupplier; import org.springframework.web.filter.OncePerRequestFilter; /** @@ -78,8 +80,8 @@ public ContentSecurityPolicyNonceGeneratingFilter(String attributeName) { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String nonce = this.nonceGenerator.generateKey(); - request.setAttribute(this.attributeName, nonce); + Supplier deferredNonce = SingletonSupplier.of(this.nonceGenerator::generateKey); + request.setAttribute(this.attributeName, deferredNonce); filterChain.doFilter(request, response); } diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index ea2ae30b12b..e537056af39 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -16,6 +16,8 @@ package org.springframework.security.web.header.writers; +import java.util.function.Supplier; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -152,15 +154,18 @@ public ContentSecurityPolicyHeaderWriter(String policyDirectives) { * jakarta.servlet.http.HttpServletResponse) */ @Override + @SuppressWarnings("unchecked") public void writeHeaders(HttpServletRequest request, HttpServletResponse response) { String headerName = (!this.reportOnly) ? CONTENT_SECURITY_POLICY_HEADER : CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER; if (!response.containsHeader(headerName)) { String csp; if (this.isNonceBased) { - String nonce = (String) request.getAttribute(this.nonceAttributeName); - Assert.state(nonce != null, "Nonce is unset"); - csp = this.policyDirectives.replace(NONCE_PLACEHOLDER, nonce); + Supplier deferredNonce = (Supplier) request.getAttribute(this.nonceAttributeName); + Assert.state(deferredNonce != null, + () -> "Failed to replace {nonce} placeholders since no nonce found as a request attribute " + + this.nonceAttributeName); + csp = this.policyDirectives.replace(NONCE_PLACEHOLDER, deferredNonce.get()); } else { csp = this.policyDirectives; diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java index d6ad5849e6d..9246e7a4874 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java @@ -74,10 +74,9 @@ public ContentSecurityPolicyNonceGeneratingWebFilter(String attributeName) { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - return Mono.fromSupplier(this.nonceGenerator::generateKey).flatMap((nonce) -> { - exchange.getAttributes().put(this.attributeName, nonce); - return chain.filter(exchange); - }); + Mono deferredNonce = Mono.fromSupplier(this.nonceGenerator::generateKey).cache(); + exchange.getAttributes().put(this.attributeName, deferredNonce); + return chain.filter(exchange); } } diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java index 418be626640..0cf7dd32ec2 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -78,17 +78,26 @@ public Mono writeHttpHeaders(ServerWebExchange exchange) { return Mono.justOrEmpty(this.policyDirectives).flatMap((csp) -> { String headerName = resolveHeader(this.reportOnly); HttpHeaders headers = exchange.getResponse().getHeaders(); - if (!headers.containsHeader(headerName)) { - if (this.isNonceBased) { - String nonce = exchange.getAttribute(this.nonceAttributeName); - if (nonce == null) { - return Mono.error(new IllegalStateException("Nonce is unset")); - } - csp = csp.replace(NONCE_PLACEHOLDER, nonce); - } + + if (headers.containsHeader(headerName)) { + return Mono.empty(); + } + + if (!this.isNonceBased) { headers.put(headerName, List.of(csp)); + return Mono.empty(); + } + + Mono deferredNonce = exchange.getAttribute(this.nonceAttributeName); + if (deferredNonce == null) { + return Mono.error(new IllegalStateException( + "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " + + this.nonceAttributeName)); } - return Mono.empty(); + return deferredNonce.flatMap((nonce) -> { + headers.put(headerName, List.of(csp.replace(NONCE_PLACEHOLDER, nonce))); + return Mono.empty(); + }); }); } diff --git a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java index 457c63b8d6d..79900ba3654 100644 --- a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java @@ -16,6 +16,8 @@ package org.springframework.security.web.header; +import java.util.function.Supplier; + import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; @@ -28,8 +30,11 @@ import org.springframework.security.crypto.keygen.KeyGenerators; import org.springframework.security.crypto.keygen.StringKeyGenerator; +import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; +import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -54,9 +59,10 @@ void attributeShouldBeAddedAndNonceIsLongEnoughBase64ByDefault() throws Exceptio Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(ATTRIBUTE_NAME); filter.doFilter(request, response, chain); - assertThat(request.getAttribute(ATTRIBUTE_NAME)).isInstanceOfSatisfying(String.class, - (nonce) -> assertThat(nonce).isBase64() - .hasSizeGreaterThanOrEqualTo((int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE))); + assertThat(request.getAttribute(ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get, as(STRING)) + .isBase64() + .hasSizeGreaterThanOrEqualTo((int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE)); then(chain).should().doFilter(request, response); } @@ -72,7 +78,9 @@ void customNonceGeneratorIsUsed() throws Exception { Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(ATTRIBUTE_NAME, nonceGenerator); filter.doFilter(request, response, chain); - assertThat(request.getAttribute(ATTRIBUTE_NAME)).isSameAs(nonce); + assertThat(request.getAttribute(ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get, as(STRING)) + .isSameAs(nonce); then(nonceGenerator).should().generateKey(); } diff --git a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java index 7f06db6a8eb..4dccf9d7fb9 100644 --- a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java @@ -16,6 +16,8 @@ package org.springframework.security.web.header.writers; +import java.util.function.Supplier; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -147,7 +149,7 @@ public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() @Test public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - this.request.setAttribute(DEFAULT_NONCE_ATTRIBUTE_NAME, "Test+Nonce+Value"); + this.request.setAttribute(DEFAULT_NONCE_ATTRIBUTE_NAME, (Supplier) () -> "Test+Nonce+Value"); this.writer.writeHeaders(this.request, this.response); assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_HEADER)) .isEqualTo("script-src 'nonce-Test+Nonce+Value'; style-src 'nonce-Test+Nonce+Value'"); @@ -159,8 +161,8 @@ public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() String customAttributeName = "custom-attribute-name"; this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); this.writer.setNonceAttributeName(customAttributeName); - this.request.setAttribute(DEFAULT_NONCE_ATTRIBUTE_NAME, "SHOULD+NOT+USE"); - this.request.setAttribute(customAttributeName, "For/Custom/Nonce/Attribute/Name"); + this.request.setAttribute(DEFAULT_NONCE_ATTRIBUTE_NAME, (Supplier) () -> "SHOULD+NOT+USE"); + this.request.setAttribute(customAttributeName, (Supplier) () -> "For/Custom/Nonce/Attribute/Name"); this.writer.writeHeaders(this.request, this.response); assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_HEADER)) .isEqualTo("script-src 'nonce-For/Custom/Nonce/Attribute/Name'"); @@ -172,7 +174,8 @@ public void writeNonceBasedCspWhenNonceUnsetThenThrows() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); this.writer.setNonceAttributeName(DEFAULT_NONCE_ATTRIBUTE_NAME); assertThatIllegalStateException().isThrownBy(() -> this.writer.writeHeaders(this.request, this.response)) - .withMessage("Nonce is unset"); + .withMessage( + "Failed to replace {nonce} placeholders since no nonce found as a request attribute _csp_nonce"); } } diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java index 5bd6878cb68..26133d0cae6 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java @@ -52,9 +52,12 @@ void attributeShouldBeAddedAndNonceIsLongEnoughBase64ByDefault() { WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(ATTRIBUTE_NAME); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); - String nonce = exchange.getRequiredAttribute(ATTRIBUTE_NAME); + Mono deferredNonce = exchange.getRequiredAttribute(ATTRIBUTE_NAME); - assertThat(nonce).isBase64().hasSizeGreaterThanOrEqualTo((int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE)); + StepVerifier.create(deferredNonce) + .assertNext((nonce) -> assertThat(nonce).isBase64() + .hasSizeGreaterThanOrEqualTo((int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE))) + .verifyComplete(); then(chain).should().filter(exchange); } @@ -69,8 +72,9 @@ void customNonceGeneratorIsUsed() { WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(ATTRIBUTE_NAME, nonceGenerator); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); + Mono deferredNonce = exchange.getRequiredAttribute(ATTRIBUTE_NAME); - assertThat((String) exchange.getRequiredAttribute(ATTRIBUTE_NAME)).isSameAs(nonce); + StepVerifier.create(deferredNonce).expectNext(nonce).verifyComplete(); then(nonceGenerator).should().generateKey(); } diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java index 8875e9ea138..658db9f51c4 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import org.springframework.http.HttpHeaders; @@ -114,7 +115,7 @@ public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() @Test public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - this.exchange.getAttributes().put(DEFAULT_NONCE_ATTRIBUTE_NAME, "Test+Nonce+Value"); + this.exchange.getAttributes().put(DEFAULT_NONCE_ATTRIBUTE_NAME, Mono.just("Test+Nonce+Value")); StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)).verifyComplete(); HttpHeaders headers = this.exchange.getResponse().getHeaders(); assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)) @@ -127,8 +128,8 @@ public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() String customAttributeName = "custom-attribute-name"; this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); this.writer.setNonceAttributeName(customAttributeName); - this.exchange.getAttributes().put(DEFAULT_NONCE_ATTRIBUTE_NAME, "SHOULD+NOT+USE"); - this.exchange.getAttributes().put(customAttributeName, "For/Custom/Nonce/Attribute/Name"); + this.exchange.getAttributes().put(DEFAULT_NONCE_ATTRIBUTE_NAME, Mono.just("SHOULD+NOT+USE")); + this.exchange.getAttributes().put(customAttributeName, Mono.just("For/Custom/Nonce/Attribute/Name")); StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)).verifyComplete(); HttpHeaders headers = this.exchange.getResponse().getHeaders(); assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)) @@ -141,8 +142,9 @@ public void writeNonceBasedCspWhenNonceUnsetThenEmitError() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); this.writer.setNonceAttributeName(DEFAULT_NONCE_ATTRIBUTE_NAME); StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)) - .expectErrorSatisfies( - (ex) -> assertThat(ex).isInstanceOf(IllegalStateException.class).hasMessage("Nonce is unset")) + .expectErrorSatisfies((ex) -> assertThat(ex).isInstanceOf(IllegalStateException.class) + .hasMessage( + "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute _csp_nonce")) .verify(); } From 6e3e0946ca88a79f00328b84d9af8853e187af42 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Wed, 1 Apr 2026 12:55:07 +0800 Subject: [PATCH 07/26] Remove Javadoc since tags in CSP tests Signed-off-by: Ziqin Wang --- .../annotation/web/configurers/HeadersConfigurerTests.java | 5 ----- .../security/config/web/server/HeaderSpecTests.java | 5 ----- .../annotation/web/headers/ContentSecurityPolicyDslTests.kt | 5 ----- .../config/web/server/ServerContentSecurityPolicyDslTests.kt | 5 ----- .../ContentSecurityPolicyNonceGeneratingFilterTests.java | 1 - .../writers/ContentSecurityPolicyHeaderWriterTests.java | 4 ---- .../ContentSecurityPolicyNonceGeneratingWebFilterTests.java | 1 - .../ContentSecurityPolicyServerHttpHeadersWriterTests.java | 4 ---- 8 files changed, 30 deletions(-) diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java index 1fe6ced7b55..e5e30305a90 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java @@ -426,7 +426,6 @@ public void configureWhenContentSecurityPolicyNoPolicyDirectivesInLambdaThenDefa assertThat(mvcResult.getResponse().getHeaderNames()).containsExactly(HttpHeaders.CONTENT_SECURITY_POLICY); } - /** @since 7.1 */ @Test public void configureWhenContentSecurityPolicyWithDefaultNonceThenHeaderMatchesContent() throws Exception { Pattern regex = Pattern.compile("^script-src 'self' 'nonce-([A-Za-z0-9+/]{22,}={0,2})'$"); @@ -444,7 +443,6 @@ public void configureWhenContentSecurityPolicyWithDefaultNonceThenHeaderMatchesC }); } - /** @since 7.1 */ @Test public void configureWhenContentSecurityPolicyWithCustomNonceThenHeaderMatchesContent() throws Exception { Pattern regex = Pattern.compile("^script-src 'self' 'nonce-([A-Za-z0-9+/]{22,}={0,2})'$"); @@ -462,7 +460,6 @@ public void configureWhenContentSecurityPolicyWithCustomNonceThenHeaderMatchesCo }); } - /** @since 7.1 */ @Test public void configureWhenContentSecurityPolicyWithMatcherThenHeaderInResponseIfMatched() throws Exception { this.spring.register(ContentSecurityPolicyMatcherConfig.class).autowire(); @@ -472,7 +469,6 @@ public void configureWhenContentSecurityPolicyWithMatcherThenHeaderInResponseIfM .andExpect(header().doesNotExist(HttpHeaders.CONTENT_SECURITY_POLICY)); } - /** @since 7.1 */ @Test public void configureWhenContentSecurityPolicyWithPathMatchersThenHeaderInResponseIfMatched() throws Exception { this.spring.register(ContentSecurityPolicyPathMatchersConfig.class).autowire(); @@ -484,7 +480,6 @@ public void configureWhenContentSecurityPolicyWithPathMatchersThenHeaderInRespon .andExpect(header().doesNotExist(HttpHeaders.CONTENT_SECURITY_POLICY)); } - /** @since 7.1 */ @Test public void configureWhenContentSecurityPolicyWithOverriddenMatchersThenThrows() { assertThatException() diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index fec1e22ff63..65a5edd56dd 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -401,7 +401,6 @@ public void headersWhenContentSecurityPolicyEnabledInLambdaThenContentSecurityPo assertHeaders(); } - /** @since 7.1 */ @Test public void headersWhenContentSecurityPolicyEnabledWithDefaultNonceThenHeaderMatchesContent() { String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; @@ -429,7 +428,6 @@ public void headersWhenContentSecurityPolicyEnabledWithDefaultNonceThenHeaderMat }); } - /** @since 7.1 */ @Test public void headersWhenContentSecurityPolicyEnabledWithCustomNonceThenHeaderMatchesContent() { String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; @@ -458,7 +456,6 @@ public void headersWhenContentSecurityPolicyEnabledWithCustomNonceThenHeaderMatc }); } - /** @since 7.1 */ @Test public void headersWhenContentSecurityPolicyEnabledWithMatcherThenHeaderInResponseIfMatched() { String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; @@ -487,7 +484,6 @@ public void headersWhenContentSecurityPolicyEnabledWithMatcherThenHeaderInRespon // @formatter:on } - /** @since 7.1 */ @Test public void headersWhenContentSecurityPolicyEnabledWithPathMatchersThenHeaderInResponseIfMatched() { String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY; @@ -515,7 +511,6 @@ public void headersWhenContentSecurityPolicyEnabledWithPathMatchersThenHeaderInR // @formatter:on } - /** @since 7.1 */ @Test public void headersWhenContentSecurityPolicyWithOverriddenMatchersThenFailToConfigure() { // @formatter:off diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt index 4d6610b1192..39cb2dd78ce 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt @@ -139,7 +139,6 @@ class ContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `headers when content security policy configured with default nonce attribute then header in response`() { this.spring.register(ContentSecurityPolicyDefaultNonceConfig::class.java).autowire() @@ -171,7 +170,6 @@ class ContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `headers when content security policy configured with custom nonce attribute then header in response`() { this.spring.register(ContentSecurityPolicyCustomNonceConfig::class.java).autowire() @@ -204,7 +202,6 @@ class ContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `headers when content security policy configured with matcher then header in response if matched`() { this.spring.register(ContentSecurityPolicyMatcherConfig::class.java).autowire() @@ -247,7 +244,6 @@ class ContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `headers when content security policy configured with path matchers then header in response if matched`() { this.spring.register(ContentSecurityPolicyPathMatchersConfig::class.java).autowire() @@ -293,7 +289,6 @@ class ContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `headers when content security policy configured with overridden matchers then throws`() { assertThatThrownBy { diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt index 91f96950f08..8c6013d4dbb 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt @@ -131,7 +131,6 @@ class ServerContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `request when configured with default nonce then CSP header with nonce in response`() { this.spring.register(CspDefaultNonceConfig::class.java).autowire() @@ -159,7 +158,6 @@ class ServerContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `request when configured with custom nonce then CSP header with nonce in response`() { this.spring.register(CspCustomNonceConfig::class.java).autowire() @@ -188,7 +186,6 @@ class ServerContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `request when configured with matcher then CSP header in response if matched`() { val headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY @@ -226,7 +223,6 @@ class ServerContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `request when configured with path matchers then CSP header in response if matched`() { val headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY @@ -261,7 +257,6 @@ class ServerContentSecurityPolicyDslTests { } } - /** @since 7.1 */ @Test fun `when matchers overridden then fails to configure`() { assertThatThrownBy { diff --git a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java index 79900ba3654..75e0963afab 100644 --- a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java @@ -42,7 +42,6 @@ /** * @author Ziqin Wang - * @since 7.l */ class ContentSecurityPolicyNonceGeneratingFilterTests { diff --git a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java index 4dccf9d7fb9..15277c25b9a 100644 --- a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java @@ -132,7 +132,6 @@ public void writeContentSecurityPolicyReportOnlyHeaderWhenNotPresent() { assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER)).isSameAs(value); } - /** @since 7.1 */ @Test public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() { this.writer.setPolicyDirectives("script-src 'self' 'nonce-{nonce}'"); @@ -145,7 +144,6 @@ public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() assertThat(this.writer.isNonceBased()).isFalse(); } - /** @since 7.1 */ @Test public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); @@ -155,7 +153,6 @@ public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { .isEqualTo("script-src 'nonce-Test+Nonce+Value'; style-src 'nonce-Test+Nonce+Value'"); } - /** @since 7.1 */ @Test public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() { String customAttributeName = "custom-attribute-name"; @@ -168,7 +165,6 @@ public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() .isEqualTo("script-src 'nonce-For/Custom/Nonce/Attribute/Name'"); } - /** @since 7.1 */ @Test public void writeNonceBasedCspWhenNonceUnsetThenThrows() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java index 26133d0cae6..a4a2b879fd2 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java @@ -36,7 +36,6 @@ /** * @author Ziqin Wang - * @since 7.1 */ class ContentSecurityPolicyNonceGeneratingWebFilterTests { diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java index 658db9f51c4..c96054eed83 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java @@ -98,7 +98,6 @@ public void writeHeadersWhenAlreadyWrittenThenDoesNotOverride() { assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)).containsOnly(headerValue); } - /** @since 7.1 */ @Test public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() { this.writer.setPolicyDirectives("script-src 'self' 'nonce-{nonce}'"); @@ -111,7 +110,6 @@ public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() assertThat(this.writer.isNonceBased()).isFalse(); } - /** @since 7.1 */ @Test public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); @@ -122,7 +120,6 @@ public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { .containsOnly("script-src 'nonce-Test+Nonce+Value'; style-src 'nonce-Test+Nonce+Value'"); } - /** @since 7.1 */ @Test public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() { String customAttributeName = "custom-attribute-name"; @@ -136,7 +133,6 @@ public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() .containsOnly("script-src 'nonce-For/Custom/Nonce/Attribute/Name'"); } - /** @since 7.1 */ @Test public void writeNonceBasedCspWhenNonceUnsetThenEmitError() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); From 855e946628e5c181781b0ac0361869a208c79f7d Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Wed, 1 Apr 2026 12:58:28 +0800 Subject: [PATCH 08/26] Fix typo in ContentSecurityPolicyDsl KDoc Signed-off-by: Ziqin Wang --- .../config/annotation/web/headers/ContentSecurityPolicyDsl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt index 35dadd1d935..0f9516506b0 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt @@ -32,7 +32,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher class ContentSecurityPolicyDsl { /** * The security policy directive(s) to be used in the response header. - * The [policyDirectives] may contain `{code}` as placeholders for a generated secure + * The [policyDirectives] may contain `{nonce}` as placeholders for a generated secure * random nonce, e.g., `script-src 'self' 'nonce-{nonce}'`. */ var policyDirectives: String? = null From bf6f1d4f26ef9defbb2aa73dab902016e8e82968 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Wed, 1 Apr 2026 18:11:56 +0800 Subject: [PATCH 09/26] Separate internal nonce attr from attr exposed for views Signed-off-by: Ziqin Wang --- .../web/configurers/HeadersConfigurer.java | 12 +++- .../config/web/server/ServerHttpSecurity.java | 11 ++-- ...ntSecurityPolicyNonceGeneratingFilter.java | 33 ++++++---- .../ContentSecurityPolicyHeaderWriter.java | 35 ++--------- ...ecurityPolicyNonceGeneratingWebFilter.java | 37 +++++++---- ...SecurityPolicyServerHttpHeadersWriter.java | 34 +---------- ...urityPolicyNonceGeneratingFilterTests.java | 61 ++++++++++++++----- ...ontentSecurityPolicyHeaderWriterTests.java | 25 ++------ ...tyPolicyNonceGeneratingWebFilterTests.java | 61 +++++++++++++------ ...ityPolicyServerHttpHeadersWriterTests.java | 25 ++------ 10 files changed, 168 insertions(+), 166 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java index 904f7b81106..335fad4673c 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java @@ -286,8 +286,11 @@ public void configure(H http) { private void configureCspNonceGeneratingFilter(H http) { ContentSecurityPolicyHeaderWriter writer = this.contentSecurityPolicy.writer; if (writer != null && writer.isNonceBased()) { - http.addFilterBefore(new ContentSecurityPolicyNonceGeneratingFilter(writer.getNonceAttributeName()), - HeaderWriterFilter.class); + ContentSecurityPolicyNonceGeneratingFilter filter = new ContentSecurityPolicyNonceGeneratingFilter(); + if (this.contentSecurityPolicy.nonceAttributeName != null) { + filter.setAttributeName(this.contentSecurityPolicy.nonceAttributeName); + } + http.addFilterBefore(filter, HeaderWriterFilter.class); } } @@ -953,6 +956,8 @@ public final class ContentSecurityPolicyConfig { private ContentSecurityPolicyHeaderWriter writer; + private @Nullable String nonceAttributeName; + private @Nullable RequestMatcher requestMatcher; private ContentSecurityPolicyConfig() { @@ -991,7 +996,8 @@ public ContentSecurityPolicyConfig reportOnly() { * @since 7.1 */ public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) { - this.writer.setNonceAttributeName(nonceAttributeName); + Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); + this.nonceAttributeName = nonceAttributeName; return this; } diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 250ad0cfdb5..6d242de13b8 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2486,6 +2486,8 @@ public final class HeaderSpec { private CrossOriginResourcePolicyServerHttpHeadersWriter crossOriginResourcePolicy = new CrossOriginResourcePolicyServerHttpHeadersWriter(); + private final ContentSecurityPolicyNonceGeneratingWebFilter nonceGeneratingFilter = new ContentSecurityPolicyNonceGeneratingWebFilter(); + private HeaderSpec() { this.writers = new ArrayList<>(Arrays.asList(this.cacheControl, this.contentTypeOptions, this.hsts, this.frameOptions, this.xss, this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy, @@ -2564,10 +2566,7 @@ protected void configure(ServerHttpSecurity http) { HttpHeaderWriterWebFilter result = new HttpHeaderWriterWebFilter(writer); http.addFilterAt(result, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); if (this.contentSecurityPolicy.isNonceBased()) { - http.addFilterBefore( - new ContentSecurityPolicyNonceGeneratingWebFilter( - this.contentSecurityPolicy.getNonceAttributeName()), - SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); + http.addFilterBefore(this.nonceGeneratingFilter, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); } } @@ -2875,7 +2874,7 @@ public HeaderSpec policyDirectives(String policyDirectives) { } /** - * Sets the name of the {@link ServerWebExchange#getAttribute(String) request + * Sets the name of the {@link ServerWebExchange#getAttribute(String) exchange * attribute} for the generated nonce. Views can read this attribute to render * the nonce in HTML. * @param nonceAttributeName the name of the nonce attribute @@ -2885,7 +2884,7 @@ public HeaderSpec policyDirectives(String policyDirectives) { * @since 7.1 */ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { - HeaderSpec.this.contentSecurityPolicy.setNonceAttributeName(nonceAttributeName); + HeaderSpec.this.nonceGeneratingFilter.setAttributeName(nonceAttributeName); return this; } diff --git a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java index 3f94b59bbf5..e1f9044cfdf 100644 --- a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -46,21 +46,17 @@ */ public final class ContentSecurityPolicyNonceGeneratingFilter extends OncePerRequestFilter { - private final String attributeName; + private String attributeName = "_csp_nonce"; private final StringKeyGenerator nonceGenerator; /** * Creates a new instance. - * @param attributeName the name of the request attribute to generate * @param nonceGenerator a {@link StringKeyGenerator} for generating nonce - * @throws IllegalArgumentException if {@code attributeName} is null or empty string, - * or {@code nonceGenerator} is null + * @throws IllegalArgumentException if {@code nonceGenerator} is {@code null} */ - public ContentSecurityPolicyNonceGeneratingFilter(String attributeName, StringKeyGenerator nonceGenerator) { - Assert.hasLength(attributeName, "AttributeName must not be null or empty"); + public ContentSecurityPolicyNonceGeneratingFilter(StringKeyGenerator nonceGenerator) { Assert.notNull(nonceGenerator, "NonceGenerator must not be null"); - this.attributeName = attributeName; this.nonceGenerator = nonceGenerator; } @@ -69,11 +65,9 @@ public ContentSecurityPolicyNonceGeneratingFilter(String attributeName, StringKe *

* For each request, the created filter will generate a secure random nonce value with * 128-bit entropy and encode it as a Base64 string without padding. - * @param attributeName the name of the request attribute to generate - * @throws IllegalArgumentException if {@code attributeName} is null or empty string */ - public ContentSecurityPolicyNonceGeneratingFilter(String attributeName) { - this(attributeName, new Base64StringKeyGenerator(Base64.getEncoder().withoutPadding(), 16)); + public ContentSecurityPolicyNonceGeneratingFilter() { + this(new Base64StringKeyGenerator(Base64.getEncoder().withoutPadding(), 16)); } @Override @@ -81,8 +75,25 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throws ServletException, IOException { Supplier deferredNonce = SingletonSupplier.of(this.nonceGenerator::generateKey); + + // For internal use + request.setAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName(), deferredNonce); + + // Exposed to users request.setAttribute(this.attributeName, deferredNonce); + filterChain.doFilter(request, response); } + /** + * Set the name of the request attribute to generate. + * @param attributeName the name of the request attribute to generate + * @throws IllegalArgumentException if {@code attributeName} is {@code null} or empty + * string + */ + public void setAttributeName(String attributeName) { + Assert.hasLength(attributeName, "AttributeName must not be null or empty"); + this.attributeName = attributeName; + } + } diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index e537056af39..e5a7b0f332c 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; import org.springframework.security.web.header.HeaderWriter; import org.springframework.util.Assert; @@ -119,8 +120,6 @@ public final class ContentSecurityPolicyHeaderWriter implements HeaderWriter { private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'"; - public static final String DEFAULT_NONCE_ATTRIBUTE_NAME = "_csp_nonce"; - public static final String NONCE_PLACEHOLDER = "{nonce}"; private String policyDirectives; @@ -129,8 +128,6 @@ public final class ContentSecurityPolicyHeaderWriter implements HeaderWriter { private boolean isNonceBased; - private String nonceAttributeName = DEFAULT_NONCE_ATTRIBUTE_NAME; - /** * Creates a new instance. Default value: default-src 'self' */ @@ -161,10 +158,11 @@ public void writeHeaders(HttpServletRequest request, HttpServletResponse respons if (!response.containsHeader(headerName)) { String csp; if (this.isNonceBased) { - Supplier deferredNonce = (Supplier) request.getAttribute(this.nonceAttributeName); + Supplier deferredNonce = (Supplier) request + .getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName()); Assert.state(deferredNonce != null, () -> "Failed to replace {nonce} placeholders since no nonce found as a request attribute " - + this.nonceAttributeName); + + ContentSecurityPolicyNonceGeneratingFilter.class.getName()); csp = this.policyDirectives.replace(NONCE_PLACEHOLDER, deferredNonce.get()); } else { @@ -196,29 +194,6 @@ public void setReportOnly(boolean reportOnly) { this.reportOnly = reportOnly; } - /** - * Sets the name of the servlet request attribute from which the nonce value is taken. - * Defaults to {@code _csp_nonce} if unset. - * @param nonceAttributeName the name of the nonce attribute - * @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null} or - * empty - * @since 7.1 - */ - public void setNonceAttributeName(String nonceAttributeName) { - Assert.hasLength(nonceAttributeName, "nonceAttributeName cannot be null or empty"); - this.nonceAttributeName = nonceAttributeName; - } - - /** - * Returns the name of the servlet request attribute from which the nonce value is - * taken. Defaults to {@code _csp_nonce} if unset. - * @return the name of the nonce attribute. - * @since 7.1 - */ - public String getNonceAttributeName() { - return this.nonceAttributeName; - } - /** * Returns whether the content security policy is nonce-based. The CSP is considered * nonce-based if the configured {@code policyDirectives} string contains a @@ -233,7 +208,7 @@ public boolean isNonceBased() { @Override public String toString() { return getClass().getName() + " [policyDirectives=" + this.policyDirectives + "; reportOnly=" + this.reportOnly - + "; isNonceBased=" + this.isNonceBased + "; nonceAttributeName=" + this.nonceAttributeName + "]"; + + "; isNonceBased=" + this.isNonceBased + "]"; } } diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java index 9246e7a4874..c7d5c759e01 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java @@ -17,6 +17,7 @@ package org.springframework.security.web.server.header; import java.util.Base64; +import java.util.Map; import reactor.core.publisher.Mono; @@ -42,21 +43,17 @@ */ public final class ContentSecurityPolicyNonceGeneratingWebFilter implements WebFilter { - private final String attributeName; + private String attributeName = "_csp_nonce"; private final StringKeyGenerator nonceGenerator; /** * Creates a new instance. - * @param attributeName the name of the request attribute to generate * @param nonceGenerator a {@link StringKeyGenerator} for generating nonce - * @throws IllegalArgumentException if {@code attributeName} is null or empty string, - * or {@code nonceGenerator} is null + * @throws IllegalArgumentException if {@code nonceGenerator} is {@code null} */ - public ContentSecurityPolicyNonceGeneratingWebFilter(String attributeName, StringKeyGenerator nonceGenerator) { - Assert.hasLength(attributeName, "AttributeName must not be null or empty"); + public ContentSecurityPolicyNonceGeneratingWebFilter(StringKeyGenerator nonceGenerator) { Assert.notNull(nonceGenerator, "NonceGenerator must not be null"); - this.attributeName = attributeName; this.nonceGenerator = nonceGenerator; } @@ -65,18 +62,34 @@ public ContentSecurityPolicyNonceGeneratingWebFilter(String attributeName, Strin *

* For each exchange, the created filter will generate a secure random nonce value * with 128-bit entropy and encode it as a Base64 string without padding. - * @param attributeName the name of the exchange attribute to generate - * @throws IllegalArgumentException if {@code attributeName} is null or empty string */ - public ContentSecurityPolicyNonceGeneratingWebFilter(String attributeName) { - this(attributeName, new Base64StringKeyGenerator(Base64.getEncoder().withoutPadding(), 16)); + public ContentSecurityPolicyNonceGeneratingWebFilter() { + this(new Base64StringKeyGenerator(Base64.getEncoder().withoutPadding(), 16)); } @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { Mono deferredNonce = Mono.fromSupplier(this.nonceGenerator::generateKey).cache(); - exchange.getAttributes().put(this.attributeName, deferredNonce); + Map attributes = exchange.getAttributes(); + + // For internal use + attributes.put(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName(), deferredNonce); + + // Exposed to users + attributes.put(this.attributeName, deferredNonce); + return chain.filter(exchange); } + /** + * Sets the name of the exchange attribute to generate. + * @param attributeName the name of the exchange attribute to generate + * @throws IllegalArgumentException if {@code attributeName} is {@code null} or empty + * string + */ + public void setAttributeName(String attributeName) { + Assert.hasLength(attributeName, "AttributeName must not be null or empty"); + this.attributeName = attributeName; + } + } diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java index 0cf7dd32ec2..10b608eb6ed 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -61,16 +61,12 @@ public final class ContentSecurityPolicyServerHttpHeadersWriter implements Serve public static final String CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only"; - public static final String DEFAULT_NONCE_ATTRIBUTE_NAME = "_csp_nonce"; - public static final String NONCE_PLACEHOLDER = "{nonce}"; private @Nullable String policyDirectives; private boolean reportOnly; - private String nonceAttributeName = DEFAULT_NONCE_ATTRIBUTE_NAME; - private boolean isNonceBased; @Override @@ -88,11 +84,12 @@ public Mono writeHttpHeaders(ServerWebExchange exchange) { return Mono.empty(); } - Mono deferredNonce = exchange.getAttribute(this.nonceAttributeName); + Mono deferredNonce = exchange + .getAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); if (deferredNonce == null) { return Mono.error(new IllegalStateException( "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " - + this.nonceAttributeName)); + + ContentSecurityPolicyNonceGeneratingWebFilter.class.getName())); } return deferredNonce.flatMap((nonce) -> { headers.put(headerName, List.of(csp.replace(NONCE_PLACEHOLDER, nonce))); @@ -123,31 +120,6 @@ public void setReportOnly(boolean reportOnly) { this.reportOnly = reportOnly; } - /** - * Sets the name of the {@link ServerWebExchange#getAttribute(String) exchange - * attribute} from which the nonce value is taken. Defaults to {@code _csp_nonce} if - * unset. - * @param nonceAttributeName the name of the nonce attribute - * @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null} or - * empty - * @since 7.1 - */ - public void setNonceAttributeName(String nonceAttributeName) { - Assert.hasLength(nonceAttributeName, "nonceAttributeName cannot be null or empty"); - this.nonceAttributeName = nonceAttributeName; - } - - /** - * Returns the name of the {@link ServerWebExchange#getAttribute(String) request - * attribute} from which the nonce value is taken. Defaults to {@code _csp_nonce} if - * unset. - * @return the name of the nonce attribute. - * @since 7.1 - */ - public String getNonceAttributeName() { - return this.nonceAttributeName; - } - /** * Returns whether the content security policy is nonce-based. The CSP is considered * nonce-based if the configured {@code policyDirectives} string contains a diff --git a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java index 75e0963afab..de6cab2827c 100644 --- a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java @@ -45,7 +45,7 @@ */ class ContentSecurityPolicyNonceGeneratingFilterTests { - private static final String ATTRIBUTE_NAME = "TEST_NONCE_ATTR"; + private static final String DEFAULT_ATTRIBUTE_NAME = "_csp_nonce"; private static final int MIN_STRENGTH_IN_BYTE = 16; @@ -55,13 +55,40 @@ void attributeShouldBeAddedAndNonceIsLongEnoughBase64ByDefault() throws Exceptio HttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = spy(new MockFilterChain()); - Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(ATTRIBUTE_NAME); + Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(); filter.doFilter(request, response, chain); - assertThat(request.getAttribute(ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) + int minExpectedLength = (int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE); + assertThat(request.getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName())) + .asInstanceOf(type(Supplier.class)) .extracting(Supplier::get, as(STRING)) .isBase64() - .hasSizeGreaterThanOrEqualTo((int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE)); + .hasSizeGreaterThanOrEqualTo(minExpectedLength); + assertThat(request.getAttribute(DEFAULT_ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get, as(STRING)) + .isBase64() + .hasSizeGreaterThanOrEqualTo(minExpectedLength); + then(chain).should().doFilter(request, response); + } + + @Test + void customAttributeNameIsUsed() throws Exception { + HttpServletRequest request = new MockHttpServletRequest(); + HttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = spy(new MockFilterChain()); + String customAttributeName = "TEST_NONCE_ATTR"; + + var filter = new ContentSecurityPolicyNonceGeneratingFilter(); + filter.setAttributeName(customAttributeName); + filter.doFilter(request, response, chain); + + assertThat(request.getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName())) + .asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get, as(STRING)) + .isBase64(); + assertThat(request.getAttribute(customAttributeName)).asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get, as(STRING)) + .isBase64(); then(chain).should().doFilter(request, response); } @@ -74,10 +101,14 @@ void customNonceGeneratorIsUsed() throws Exception { StringKeyGenerator nonceGenerator = mock(); given(nonceGenerator.generateKey()).willReturn(nonce); - Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(ATTRIBUTE_NAME, nonceGenerator); + Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(nonceGenerator); filter.doFilter(request, response, chain); - assertThat(request.getAttribute(ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) + assertThat(request.getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName())) + .asInstanceOf(type(Supplier.class)) + .extracting(Supplier::get, as(STRING)) + .isSameAs(nonce); + assertThat(request.getAttribute(DEFAULT_ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) .extracting(Supplier::get, as(STRING)) .isSameAs(nonce); then(nonceGenerator).should().generateKey(); @@ -86,18 +117,16 @@ void customNonceGeneratorIsUsed() throws Exception { @Test void illegalConstructorArgumentsAreRejected() { assertThatIllegalArgumentException().isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingFilter(null)) + .withMessage("NonceGenerator must not be null"); + } + + @Test + void illegalSetterArgumentsAreRejected() { + var filter = new ContentSecurityPolicyNonceGeneratingFilter(); + assertThatIllegalArgumentException().isThrownBy(() -> filter.setAttributeName(null)) .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException().isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingFilter("")) - .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException() - .isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingFilter(null, KeyGenerators.string())) - .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException() - .isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingFilter("", KeyGenerators.string())) + assertThatIllegalArgumentException().isThrownBy(() -> filter.setAttributeName("")) .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException() - .isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingFilter(ATTRIBUTE_NAME, null)) - .withMessage("NonceGenerator must not be null"); } } diff --git a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java index 15277c25b9a..d7863faa62b 100644 --- a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java @@ -23,6 +23,7 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -37,8 +38,6 @@ public class ContentSecurityPolicyHeaderWriterTests { private static final String DEFAULT_POLICY_DIRECTIVES = "default-src 'self'"; - private static final String DEFAULT_NONCE_ATTRIBUTE_NAME = "_csp_nonce"; - private MockHttpServletRequest request; private MockHttpServletResponse response; @@ -145,33 +144,21 @@ public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() } @Test - public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { + public void writeNonceBasedCspWhenNoncePresent() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - this.request.setAttribute(DEFAULT_NONCE_ATTRIBUTE_NAME, (Supplier) () -> "Test+Nonce+Value"); + this.request.setAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName(), + (Supplier) () -> "Test+Nonce+Value"); this.writer.writeHeaders(this.request, this.response); assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_HEADER)) .isEqualTo("script-src 'nonce-Test+Nonce+Value'; style-src 'nonce-Test+Nonce+Value'"); } - @Test - public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() { - String customAttributeName = "custom-attribute-name"; - this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); - this.writer.setNonceAttributeName(customAttributeName); - this.request.setAttribute(DEFAULT_NONCE_ATTRIBUTE_NAME, (Supplier) () -> "SHOULD+NOT+USE"); - this.request.setAttribute(customAttributeName, (Supplier) () -> "For/Custom/Nonce/Attribute/Name"); - this.writer.writeHeaders(this.request, this.response); - assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_HEADER)) - .isEqualTo("script-src 'nonce-For/Custom/Nonce/Attribute/Name'"); - } - @Test public void writeNonceBasedCspWhenNonceUnsetThenThrows() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); - this.writer.setNonceAttributeName(DEFAULT_NONCE_ATTRIBUTE_NAME); assertThatIllegalStateException().isThrownBy(() -> this.writer.writeHeaders(this.request, this.response)) - .withMessage( - "Failed to replace {nonce} placeholders since no nonce found as a request attribute _csp_nonce"); + .withMessage("Failed to replace {nonce} placeholders since no nonce found as a request attribute " + + ContentSecurityPolicyNonceGeneratingFilter.class.getName()); } } diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java index a4a2b879fd2..d94085e9664 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java @@ -39,7 +39,7 @@ */ class ContentSecurityPolicyNonceGeneratingWebFilterTests { - private static final String ATTRIBUTE_NAME = "TEST_NONCE_ATTR"; + private static final String DEFAULT_ATTRIBUTE_NAME = "_csp_nonce"; private static final int MIN_STRENGTH_IN_BYTE = 16; @@ -49,17 +49,41 @@ void attributeShouldBeAddedAndNonceIsLongEnoughBase64ByDefault() { WebFilterChain chain = mock(); given(chain.filter(exchange)).willReturn(Mono.empty()); - WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(ATTRIBUTE_NAME); + WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); - Mono deferredNonce = exchange.getRequiredAttribute(ATTRIBUTE_NAME); + Mono internalDeferredNonce = exchange + .getRequiredAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); + Mono deferredNonce = exchange.getRequiredAttribute(DEFAULT_ATTRIBUTE_NAME); + int minExpectedLength = (int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE); + StepVerifier.create(internalDeferredNonce) + .assertNext((nonce) -> assertThat(nonce).isBase64().hasSizeGreaterThanOrEqualTo(minExpectedLength)) + .verifyComplete(); StepVerifier.create(deferredNonce) - .assertNext((nonce) -> assertThat(nonce).isBase64() - .hasSizeGreaterThanOrEqualTo((int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE))) + .assertNext((nonce) -> assertThat(nonce).isBase64().hasSizeGreaterThanOrEqualTo(minExpectedLength)) .verifyComplete(); then(chain).should().filter(exchange); } + @Test + void customAttributeNameIsUsed() { + ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); + WebFilterChain chain = mock(); + given(chain.filter(exchange)).willReturn(Mono.empty()); + String customAttributeName = "TEST_NONCE_ATTR"; + + var filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); + filter.setAttributeName(customAttributeName); + StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); + Mono internalDeferredNonce = exchange + .getRequiredAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); + Mono deferredNonce = exchange.getRequiredAttribute(customAttributeName); + + StepVerifier.create(internalDeferredNonce).assertNext((nonce) -> assertThat(nonce).isBase64()).verifyComplete(); + StepVerifier.create(deferredNonce).assertNext((nonce) -> assertThat(nonce).isBase64()).verifyComplete(); + then(chain).should().filter(exchange); + } + @Test void customNonceGeneratorIsUsed() { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/")); @@ -69,29 +93,30 @@ void customNonceGeneratorIsUsed() { StringKeyGenerator nonceGenerator = mock(); given(nonceGenerator.generateKey()).willReturn(nonce); - WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(ATTRIBUTE_NAME, nonceGenerator); + WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(nonceGenerator); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); - Mono deferredNonce = exchange.getRequiredAttribute(ATTRIBUTE_NAME); + Mono internalDeferredNonce = exchange + .getRequiredAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); + Mono deferredNonce = exchange.getRequiredAttribute(DEFAULT_ATTRIBUTE_NAME); + StepVerifier.create(internalDeferredNonce).expectNext(nonce).verifyComplete(); StepVerifier.create(deferredNonce).expectNext(nonce).verifyComplete(); then(nonceGenerator).should().generateKey(); } @Test - void illegalConstructorArgumentsAreRejected() { + void illegalConstructorArgumentIsRejected() { assertThatIllegalArgumentException().isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingWebFilter(null)) + .withMessage("NonceGenerator must not be null"); + } + + @Test + void illegalSetterArgumentsAreRejected() { + var filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); + assertThatIllegalArgumentException().isThrownBy(() -> filter.setAttributeName(null)) .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException().isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingWebFilter("")) - .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException() - .isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingWebFilter(null, KeyGenerators.string())) - .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException() - .isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingWebFilter("", KeyGenerators.string())) + assertThatIllegalArgumentException().isThrownBy(() -> filter.setAttributeName("")) .withMessage("AttributeName must not be null or empty"); - assertThatIllegalArgumentException() - .isThrownBy(() -> new ContentSecurityPolicyNonceGeneratingWebFilter(ATTRIBUTE_NAME, null)) - .withMessage("NonceGenerator must not be null"); } } diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java index c96054eed83..e8e9330fbce 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java @@ -42,8 +42,6 @@ public class ContentSecurityPolicyServerHttpHeadersWriterTests { private static final String DEFAULT_POLICY_DIRECTIVES = "default-src 'self'"; - private static final String DEFAULT_NONCE_ATTRIBUTE_NAME = "_csp_nonce"; - private ServerWebExchange exchange; private ContentSecurityPolicyServerHttpHeadersWriter writer; @@ -111,36 +109,23 @@ public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() } @Test - public void writeNonceBasedCspWhenNonceAttributeNameUnsetThenUseDefault() { + public void writeNonceBasedCspWhenNoncePresent() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - this.exchange.getAttributes().put(DEFAULT_NONCE_ATTRIBUTE_NAME, Mono.just("Test+Nonce+Value")); + this.exchange.getAttributes() + .put(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName(), Mono.just("Test+Nonce+Value")); StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)).verifyComplete(); HttpHeaders headers = this.exchange.getResponse().getHeaders(); assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)) .containsOnly("script-src 'nonce-Test+Nonce+Value'; style-src 'nonce-Test+Nonce+Value'"); } - @Test - public void writeNonceBasedCspWhenNonceAttributeNameSetThenUseCustomAttribute() { - String customAttributeName = "custom-attribute-name"; - this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); - this.writer.setNonceAttributeName(customAttributeName); - this.exchange.getAttributes().put(DEFAULT_NONCE_ATTRIBUTE_NAME, Mono.just("SHOULD+NOT+USE")); - this.exchange.getAttributes().put(customAttributeName, Mono.just("For/Custom/Nonce/Attribute/Name")); - StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)).verifyComplete(); - HttpHeaders headers = this.exchange.getResponse().getHeaders(); - assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)) - .containsOnly("script-src 'nonce-For/Custom/Nonce/Attribute/Name'"); - } - @Test public void writeNonceBasedCspWhenNonceUnsetThenEmitError() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); - this.writer.setNonceAttributeName(DEFAULT_NONCE_ATTRIBUTE_NAME); StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)) .expectErrorSatisfies((ex) -> assertThat(ex).isInstanceOf(IllegalStateException.class) - .hasMessage( - "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute _csp_nonce")) + .hasMessage("Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " + + ContentSecurityPolicyNonceGeneratingWebFilter.class.getName())) .verify(); } From 4c838b34253ae8ce56e616566f3ddb9e80283586 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 3 Apr 2026 17:14:30 +0800 Subject: [PATCH 10/26] Rename requireCspMatcher to requestMatcher To align with the rest of the DSL Signed-off-by: Ziqin Wang --- .../web/configurers/HeadersConfigurer.java | 16 ++++++++-------- .../config/web/server/ServerHttpSecurity.java | 16 ++++++++-------- .../web/headers/ContentSecurityPolicyDsl.kt | 16 ++++++++-------- .../web/server/ServerContentSecurityPolicyDsl.kt | 16 ++++++++-------- .../web/configurers/HeadersConfigurerTests.java | 10 +++++----- .../config/web/server/HeaderSpecTests.java | 10 +++++----- .../web/headers/ContentSecurityPolicyDslTests.kt | 10 +++++----- .../ServerContentSecurityPolicyDslTests.kt | 10 +++++----- 8 files changed, 52 insertions(+), 52 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java index 335fad4673c..836d7b10d03 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java @@ -1009,13 +1009,13 @@ public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) * @return the {@link ContentSecurityPolicyConfig} for additional configuration * @throws IllegalArgumentException if {@code requestMatcher} is null * @throws IllegalStateException if a {@link RequestMatcher} is already configured - * by a previous call of this method or {@link #requireCspMatchers(String...)} + * by a previous call of this method or {@link #requestMatchers(String...)} * @since 7.1 - * @see #requireCspMatchers(String...) + * @see #requestMatchers(String...) */ - public ContentSecurityPolicyConfig requireCspMatcher(RequestMatcher requestMatcher) { + public ContentSecurityPolicyConfig requestMatcher(RequestMatcher requestMatcher) { Assert.notNull(requestMatcher, "RequestMatcher cannot be null"); - Assert.state(this.requestMatcher == null, "RequireCspMatcher(s) is already configured"); + Assert.state(this.requestMatcher == null, "RequestMatcher(s) is already configured"); this.requestMatcher = requestMatcher; return this; } @@ -1030,14 +1030,14 @@ public ContentSecurityPolicyConfig requireCspMatcher(RequestMatcher requestMatch * @throws IllegalArgumentException if any path pattern if rejected by * {@link PathPatternRequestMatcher.Builder#matcher(String)} * @throws IllegalStateException if a {@link RequestMatcher} is already configured - * by a previous call of this method or {@link #requireCspMatcher(RequestMatcher)} + * by a previous call of this method or {@link #requestMatcher(RequestMatcher)} * @since 7.1 - * @see #requireCspMatcher(RequestMatcher) + * @see #requestMatcher(RequestMatcher) */ - public ContentSecurityPolicyConfig requireCspMatchers(String... pathPatterns) { + public ContentSecurityPolicyConfig requestMatchers(String... pathPatterns) { PathPatternRequestMatcher.Builder builder = HeadersConfigurer.this.getRequestMatcherBuilder(); OrRequestMatcher matcher = new OrRequestMatcher(Arrays.stream(pathPatterns).map(builder::matcher).toList()); - return this.requireCspMatcher(matcher); + return this.requestMatcher(matcher); } HeaderWriter getWriter() { diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 6d242de13b8..d3c27f8bf41 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2897,15 +2897,15 @@ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { * @throws IllegalArgumentException if {@code matcher} is {@code null} * @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is * already configured by a previous call of this method or - * {@link #requireCspMatchers(String...)} + * {@link #exchangeMatchers(String...)} * @since 7.1 - * @see #requireCspMatchers(String...) + * @see #exchangeMatchers(String...) */ - public ContentSecurityPolicySpec requireCspMatcher(ServerWebExchangeMatcher matcher) { + public ContentSecurityPolicySpec exchangeMatcher(ServerWebExchangeMatcher matcher) { Assert.notNull(matcher, "Matcher must not be null"); // Replace the CSP writer in the list with a matcher-decorated writer int idx = HeaderSpec.this.writers.indexOf(HeaderSpec.this.contentSecurityPolicy); - Assert.state(idx >= 0, "RequireCspMatcher(s) is already configured"); + Assert.state(idx >= 0, "ExchangeMatcher(s) is already configured"); HeaderSpec.this.writers.set(idx, new ServerWebExchangeDelegatingServerHttpHeadersWriter(matcher, HeaderSpec.this.contentSecurityPolicy)); return this; @@ -2922,12 +2922,12 @@ public ContentSecurityPolicySpec requireCspMatcher(ServerWebExchangeMatcher matc * {@link PathPatternParserServerWebExchangeMatcher} * @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is * already configured by a previous call of this method or - * {@link #requireCspMatcher(ServerWebExchangeMatcher)} + * {@link #exchangeMatcher(ServerWebExchangeMatcher)} * @since 7.1 - * @see #requireCspMatcher(ServerWebExchangeMatcher) + * @see #exchangeMatcher(ServerWebExchangeMatcher) */ - public ContentSecurityPolicySpec requireCspMatchers(String... pathPatterns) { - return this.requireCspMatcher(ServerWebExchangeMatchers.pathMatchers(pathPatterns)); + public ContentSecurityPolicySpec exchangeMatchers(String... pathPatterns) { + return this.exchangeMatcher(ServerWebExchangeMatchers.pathMatchers(pathPatterns)); } private ContentSecurityPolicySpec(String policyDirectives) { diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt index 0f9516506b0..30f71fc2b38 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt @@ -52,11 +52,11 @@ class ContentSecurityPolicyDsl { * The default is to enable CSP in every response if * [org.springframework.security.config.annotation.web.HeadersDsl.contentSecurityPolicy] * is configured. - * You can configure either this property or [requireCspMatchers], but not both. + * You can configure either this property or [requestMatchers], but not both. * @since 7.1 - * @see requireCspMatchers + * @see requestMatchers */ - var requireCspMatcher: RequestMatcher? = null + var requestMatcher: RequestMatcher? = null private var requireCspPathPatterns: Array? = null @@ -65,13 +65,13 @@ class ContentSecurityPolicyDsl { * The default is to write CSP header in every response if * [org.springframework.security.config.annotation.web.HeadersDsl.contentSecurityPolicy] * is configured. - * You can configure either this method or [requireCspMatcher], but not both. + * You can configure either this method or [requestMatcher], but not both. * @param pathPatterns the path patterns to be matched with a * [org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher] * @since 7.1 - * @see requireCspMatcher + * @see requestMatcher */ - fun requireCspMatchers(vararg pathPatterns: String) { + fun requestMatchers(vararg pathPatterns: String) { requireCspPathPatterns = pathPatterns } @@ -86,8 +86,8 @@ class ContentSecurityPolicyDsl { } } nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName) - requireCspMatcher?.also(contentSecurityPolicy::requireCspMatcher) - requireCspPathPatterns?.also(contentSecurityPolicy::requireCspMatchers) + requestMatcher?.also(contentSecurityPolicy::requestMatcher) + requireCspPathPatterns?.also(contentSecurityPolicy::requestMatchers) } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt index c401f95be0e..e017d5edb75 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt @@ -49,11 +49,11 @@ class ServerContentSecurityPolicyDsl { * The [ServerWebExchangeMatcher] to use for determining when CSP should be applied. * The default is to enable CSP in every response if [ServerHeadersDsl.contentSecurityPolicy] * is configured. - * You can configure either this property or [requireCspMatchers], but not both. + * You can configure either this property or [exchangeMatchers], but not both. * @since 7.1 - * @see requireCspMatchers + * @see exchangeMatchers */ - var requireCspMatcher: ServerWebExchangeMatcher? = null + var exchangeMatcher: ServerWebExchangeMatcher? = null private var requireCspPathPatterns: Array? = null @@ -61,13 +61,13 @@ class ServerContentSecurityPolicyDsl { * Specify the matching path patterns for determining when CSP should be applied. * The default is to enable CSP in every response if [ServerHeadersDsl.contentSecurityPolicy] * is configured. - * You can configure either this method or [requireCspMatcher], but not both. + * You can configure either this method or [exchangeMatcher], but not both. * @param pathPatterns the path patterns to be matched with a * [org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher] * @since 7.1 - * @see requireCspMatcher + * @see exchangeMatcher */ - fun requireCspMatchers(vararg pathPatterns: String) { + fun exchangeMatchers(vararg pathPatterns: String) { requireCspPathPatterns = pathPatterns } @@ -80,8 +80,8 @@ class ServerContentSecurityPolicyDsl { contentSecurityPolicy.reportOnly(reportOnly!!) } nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName) - requireCspMatcher?.also(contentSecurityPolicy::requireCspMatcher) - requireCspPathPatterns?.also(contentSecurityPolicy::requireCspMatchers) + exchangeMatcher?.also(contentSecurityPolicy::exchangeMatcher) + requireCspPathPatterns?.also(contentSecurityPolicy::exchangeMatchers) } } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java index e5e30305a90..6950ba1f3a6 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java @@ -486,7 +486,7 @@ public void configureWhenContentSecurityPolicyWithOverriddenMatchersThenThrows() .isThrownBy(() -> this.spring.register(ContentSecurityPolicyOverriddenMatchersConfig.class).autowire()) .havingRootCause() .isInstanceOf(IllegalStateException.class) - .withMessage("RequireCspMatcher(s) is already configured"); + .withMessage("RequestMatcher(s) is already configured"); } @Test @@ -1246,7 +1246,7 @@ SecurityFilterChain filterChain(HttpSecurity http) { .defaultsDisabled() .contentSecurityPolicy((csp) -> csp .policyDirectives("default-src 'self'") - .requireCspMatcher((request) -> { + .requestMatcher((request) -> { var accepted = MediaType.parseMediaTypes(request.getHeader(HttpHeaders.ACCEPT)); return MediaType.TEXT_HTML.isPresentIn(accepted); }))); @@ -1268,7 +1268,7 @@ SecurityFilterChain filterChain(HttpSecurity http) { .defaultsDisabled() .contentSecurityPolicy((csp) -> csp .policyDirectives("default-src 'self'") - .requireCspMatchers("/foo/**", "/bar/**"))); + .requestMatchers("/foo/**", "/bar/**"))); // @formatter:on return http.build(); } @@ -1287,8 +1287,8 @@ SecurityFilterChain filterChain(HttpSecurity http) { .defaultsDisabled() .contentSecurityPolicy((csp) -> csp .policyDirectives("default-src 'self'") - .requireCspMatcher(AnyRequestMatcher.INSTANCE) - .requireCspMatchers("/**"))); + .requestMatcher(AnyRequestMatcher.INSTANCE) + .requestMatchers("/**"))); // @formatter:on return http.build(); } diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index 65a5edd56dd..81befac26ad 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -463,7 +463,7 @@ public void headersWhenContentSecurityPolicyEnabledWithMatcherThenHeaderInRespon // @formatter:off this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp - .requireCspMatcher((exchange) -> + .exchangeMatcher((exchange) -> (MediaType.TEXT_HTML.isPresentIn(exchange.getRequest().getHeaders().getAccept()) ? ServerWebExchangeMatcher.MatchResult.match() : ServerWebExchangeMatcher.MatchResult.notMatch())) @@ -491,7 +491,7 @@ public void headersWhenContentSecurityPolicyEnabledWithPathMatchersThenHeaderInR // @formatter:off this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp - .requireCspMatchers("/foo/**", "/bar/**") + .exchangeMatchers("/foo/**", "/bar/**") .policyDirectives(policyDirectives))); // @formatter:on WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); @@ -518,10 +518,10 @@ public void headersWhenContentSecurityPolicyWithOverriddenMatchersThenFailToConf .isThrownBy(() -> this.http .headers((headers) -> headers .contentSecurityPolicy((csp) -> csp - .requireCspMatcher(ServerWebExchangeMatchers.anyExchange()) - .requireCspMatchers("/**") + .exchangeMatcher(ServerWebExchangeMatchers.anyExchange()) + .exchangeMatchers("/**") .policyDirectives("default-src 'self'")))) - .withMessage("RequireCspMatcher(s) is already configured"); + .withMessage("ExchangeMatcher(s) is already configured"); // @formatter:on } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt index 39cb2dd78ce..1d3af904191 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt @@ -233,7 +233,7 @@ class ContentSecurityPolicyDslTests { defaultsDisabled = true contentSecurityPolicy { policyDirectives = "default-src 'self'" - requireCspMatcher = RequestMatcher { request -> + requestMatcher = RequestMatcher { request -> val accepted = MediaType.parseMediaTypes(request.getHeader(HttpHeaders.ACCEPT)) MediaType.TEXT_HTML.isPresentIn(accepted) } @@ -281,7 +281,7 @@ class ContentSecurityPolicyDslTests { defaultsDisabled = true contentSecurityPolicy { policyDirectives = "default-src 'self'" - requireCspMatchers("/foo/**", "/bar/**") + requestMatchers("/foo/**", "/bar/**") } } } @@ -294,7 +294,7 @@ class ContentSecurityPolicyDslTests { assertThatThrownBy { this.spring.register(ContentSecurityPolicyOverriddenMatchersConfig::class.java).autowire() }.hasRootCauseInstanceOf(IllegalStateException::class.java) - .hasRootCauseMessage("RequireCspMatcher(s) is already configured") + .hasRootCauseMessage("RequestMatcher(s) is already configured") } @Configuration @@ -307,8 +307,8 @@ class ContentSecurityPolicyDslTests { defaultsDisabled = true contentSecurityPolicy { policyDirectives = "default-src 'self'" - requireCspMatcher = AnyRequestMatcher.INSTANCE - requireCspMatchers("/**") + requestMatcher = AnyRequestMatcher.INSTANCE + requestMatchers("/**") } } } diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt index 8c6013d4dbb..a01327a4d7f 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDslTests.kt @@ -212,7 +212,7 @@ class ServerContentSecurityPolicyDslTests { headers { contentSecurityPolicy { policyDirectives = "default-src 'self'" - requireCspMatcher = ServerWebExchangeMatcher { exchange -> + exchangeMatcher = ServerWebExchangeMatcher { exchange -> if (MediaType.TEXT_HTML.isPresentIn(exchange.request.headers.accept)) ServerWebExchangeMatcher.MatchResult.match() else @@ -251,7 +251,7 @@ class ServerContentSecurityPolicyDslTests { headers { contentSecurityPolicy { policyDirectives = "default-src 'self'" - requireCspMatchers("/foo/**", "/bar/**") + exchangeMatchers("/foo/**", "/bar/**") } } } @@ -262,7 +262,7 @@ class ServerContentSecurityPolicyDslTests { assertThatThrownBy { this.spring.register(CspPathOverriddenMatchersConfig::class.java).autowire() }.hasRootCauseInstanceOf(IllegalStateException::class.java) - .hasRootCauseMessage("RequireCspMatcher(s) is already configured") + .hasRootCauseMessage("ExchangeMatcher(s) is already configured") } @Configuration @@ -274,8 +274,8 @@ class ServerContentSecurityPolicyDslTests { headers { contentSecurityPolicy { policyDirectives = "default-src 'self'" - requireCspMatcher = ServerWebExchangeMatchers.anyExchange() - requireCspMatchers("/**") + exchangeMatcher = ServerWebExchangeMatchers.anyExchange() + exchangeMatchers("/**") } } } From acf956ae4e95b5f8d45b6076f9cad306ff055a3e Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 3 Apr 2026 19:46:25 +0800 Subject: [PATCH 11/26] Polish filter chain config for CSP Signed-off-by: Ziqin Wang --- .../web/configurers/HeadersConfigurer.java | 21 +++-- .../config/web/server/ServerHttpSecurity.java | 77 +++++++++++-------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java index 836d7b10d03..1bca0df9c01 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java @@ -280,18 +280,7 @@ public HeadersConfigurer defaultsDisabled() { public void configure(H http) { HeaderWriterFilter headersFilter = createHeaderWriterFilter(); http.addFilter(headersFilter); - configureCspNonceGeneratingFilter(http); - } - - private void configureCspNonceGeneratingFilter(H http) { - ContentSecurityPolicyHeaderWriter writer = this.contentSecurityPolicy.writer; - if (writer != null && writer.isNonceBased()) { - ContentSecurityPolicyNonceGeneratingFilter filter = new ContentSecurityPolicyNonceGeneratingFilter(); - if (this.contentSecurityPolicy.nonceAttributeName != null) { - filter.setAttributeName(this.contentSecurityPolicy.nonceAttributeName); - } - http.addFilterBefore(filter, HeaderWriterFilter.class); - } + http.addFilterBefore(this.contentSecurityPolicy.getNonceGeneratingFilter(), HeaderWriterFilter.class); } /** @@ -1047,6 +1036,14 @@ HeaderWriter getWriter() { return this.writer; } + ContentSecurityPolicyNonceGeneratingFilter getNonceGeneratingFilter() { + var filter = new ContentSecurityPolicyNonceGeneratingFilter(); + if (this.nonceAttributeName != null) { + filter.setAttributeName(this.nonceAttributeName); + } + return filter; + } + } public final class ReferrerPolicyConfig { diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index d3c27f8bf41..83a19dcad62 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -34,9 +34,11 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; import reactor.util.context.Context; @@ -2460,8 +2462,6 @@ protected void configure(ServerHttpSecurity http) { */ public final class HeaderSpec { - private final List writers; - private CacheControlServerHttpHeadersWriter cacheControl = new CacheControlServerHttpHeadersWriter(); private ContentTypeOptionsServerHttpHeadersWriter contentTypeOptions = new ContentTypeOptionsServerHttpHeadersWriter(); @@ -2476,7 +2476,7 @@ public final class HeaderSpec { private PermissionsPolicyServerHttpHeadersWriter permissionsPolicy = new PermissionsPolicyServerHttpHeadersWriter(); - private ContentSecurityPolicyServerHttpHeadersWriter contentSecurityPolicy = new ContentSecurityPolicyServerHttpHeadersWriter(); + private ContentSecurityPolicySpec contentSecurityPolicy = new ContentSecurityPolicySpec(); private ReferrerPolicyServerHttpHeadersWriter referrerPolicy = new ReferrerPolicyServerHttpHeadersWriter(); @@ -2486,13 +2486,9 @@ public final class HeaderSpec { private CrossOriginResourcePolicyServerHttpHeadersWriter crossOriginResourcePolicy = new CrossOriginResourcePolicyServerHttpHeadersWriter(); - private final ContentSecurityPolicyNonceGeneratingWebFilter nonceGeneratingFilter = new ContentSecurityPolicyNonceGeneratingWebFilter(); + private List customHeadersWriters = new ArrayList<>(); private HeaderSpec() { - this.writers = new ArrayList<>(Arrays.asList(this.cacheControl, this.contentTypeOptions, this.hsts, - this.frameOptions, this.xss, this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy, - this.referrerPolicy, this.crossOriginOpenerPolicy, this.crossOriginEmbedderPolicy, - this.crossOriginResourcePolicy)); } /** @@ -2546,7 +2542,7 @@ public HeaderSpec frameOptions(Customizer frameOptionsCustomiz */ public HeaderSpec writer(ServerHttpHeadersWriter serverHttpHeadersWriter) { Assert.notNull(serverHttpHeadersWriter, "serverHttpHeadersWriter cannot be null"); - this.writers.add(serverHttpHeadersWriter); + this.customHeadersWriters.add(serverHttpHeadersWriter); return this; } @@ -2562,12 +2558,18 @@ public HeaderSpec hsts(Customizer hstsCustomizer) { } protected void configure(ServerHttpSecurity http) { - ServerHttpHeadersWriter writer = new CompositeServerHttpHeadersWriter(this.writers); + Stream builtInWriters = Stream + .of(this.cacheControl, this.contentTypeOptions, this.hsts, this.frameOptions, this.xss, + this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy.getWriter(), + this.referrerPolicy, this.crossOriginOpenerPolicy, this.crossOriginEmbedderPolicy, + this.crossOriginResourcePolicy) + .filter(Objects::nonNull); + ServerHttpHeadersWriter writer = new CompositeServerHttpHeadersWriter( + Stream.concat(builtInWriters, this.customHeadersWriters.stream()).toList()); HttpHeaderWriterWebFilter result = new HttpHeaderWriterWebFilter(writer); http.addFilterAt(result, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); - if (this.contentSecurityPolicy.isNonceBased()) { - http.addFilterBefore(this.nonceGeneratingFilter, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); - } + http.addFilterBefore(this.contentSecurityPolicy.getNonceGeneratingFilter(), + SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); } /** @@ -2588,7 +2590,7 @@ public HeaderSpec xssProtection(Customizer xssProtectionCusto * @return the {@link HeaderSpec} to customize */ public HeaderSpec contentSecurityPolicy(Customizer contentSecurityPolicyCustomizer) { - contentSecurityPolicyCustomizer.customize(new ContentSecurityPolicySpec()); + contentSecurityPolicyCustomizer.customize(this.contentSecurityPolicy); return this; } @@ -2683,7 +2685,7 @@ private CacheSpec() { * @return the {@link HeaderSpec} to configure */ public HeaderSpec disable() { - HeaderSpec.this.writers.remove(HeaderSpec.this.cacheControl); + HeaderSpec.this.cacheControl = null; return HeaderSpec.this; } @@ -2704,7 +2706,7 @@ private ContentTypeOptionsSpec() { * @return the {@link HeaderSpec} to configure */ public HeaderSpec disable() { - HeaderSpec.this.writers.remove(HeaderSpec.this.contentTypeOptions); + HeaderSpec.this.contentTypeOptions = null; return HeaderSpec.this; } @@ -2736,7 +2738,7 @@ public HeaderSpec mode(XFrameOptionsServerHttpHeadersWriter.Mode mode) { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec disable() { - HeaderSpec.this.writers.remove(HeaderSpec.this.frameOptions); + HeaderSpec.this.frameOptions = null; return HeaderSpec.this; } @@ -2795,7 +2797,7 @@ public HstsSpec preload(boolean preload) { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec disable() { - HeaderSpec.this.writers.remove(HeaderSpec.this.hsts); + HeaderSpec.this.hsts = null; return HeaderSpec.this; } @@ -2816,7 +2818,7 @@ private XssProtectionSpec() { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec disable() { - HeaderSpec.this.writers.remove(HeaderSpec.this.xss); + HeaderSpec.this.xss = null; return HeaderSpec.this; } @@ -2844,8 +2846,14 @@ public final class ContentSecurityPolicySpec { private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'"; + private final ContentSecurityPolicyServerHttpHeadersWriter writer = new ContentSecurityPolicyServerHttpHeadersWriter(); + + private @Nullable String nonceAttributeName; + + private @Nullable ServerWebExchangeMatcher exchangeMatcher; + private ContentSecurityPolicySpec() { - HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(DEFAULT_SRC_SELF_POLICY); + this.writer.setPolicyDirectives(DEFAULT_SRC_SELF_POLICY); } /** @@ -2856,7 +2864,7 @@ private ContentSecurityPolicySpec() { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec reportOnly(boolean reportOnly) { - HeaderSpec.this.contentSecurityPolicy.setReportOnly(reportOnly); + this.writer.setReportOnly(reportOnly); return HeaderSpec.this; } @@ -2869,7 +2877,7 @@ public HeaderSpec reportOnly(boolean reportOnly) { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec policyDirectives(String policyDirectives) { - HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); + this.writer.setPolicyDirectives(policyDirectives); return HeaderSpec.this; } @@ -2884,7 +2892,8 @@ public HeaderSpec policyDirectives(String policyDirectives) { * @since 7.1 */ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { - HeaderSpec.this.nonceGeneratingFilter.setAttributeName(nonceAttributeName); + Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); + this.nonceAttributeName = nonceAttributeName; return this; } @@ -2903,11 +2912,8 @@ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { */ public ContentSecurityPolicySpec exchangeMatcher(ServerWebExchangeMatcher matcher) { Assert.notNull(matcher, "Matcher must not be null"); - // Replace the CSP writer in the list with a matcher-decorated writer - int idx = HeaderSpec.this.writers.indexOf(HeaderSpec.this.contentSecurityPolicy); - Assert.state(idx >= 0, "ExchangeMatcher(s) is already configured"); - HeaderSpec.this.writers.set(idx, new ServerWebExchangeDelegatingServerHttpHeadersWriter(matcher, - HeaderSpec.this.contentSecurityPolicy)); + Assert.state(this.exchangeMatcher == null, "ExchangeMatcher(s) is already configured"); + this.exchangeMatcher = matcher; return this; } @@ -2930,8 +2936,19 @@ public ContentSecurityPolicySpec exchangeMatchers(String... pathPatterns) { return this.exchangeMatcher(ServerWebExchangeMatchers.pathMatchers(pathPatterns)); } - private ContentSecurityPolicySpec(String policyDirectives) { - HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); + ServerHttpHeadersWriter getWriter() { + if (this.exchangeMatcher != null) { + return new ServerWebExchangeDelegatingServerHttpHeadersWriter(this.exchangeMatcher, this.writer); + } + return this.writer; + } + + ContentSecurityPolicyNonceGeneratingWebFilter getNonceGeneratingFilter() { + var filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); + if (this.nonceAttributeName != null) { + filter.setAttributeName(this.nonceAttributeName); + } + return filter; } } From 939ae10c57e2dee5f608323cf1dc17b08845fb2a Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 3 Apr 2026 19:48:37 +0800 Subject: [PATCH 12/26] Remove isNonceBased() from header writers Signed-off-by: Ziqin Wang --- .../writers/ContentSecurityPolicyHeaderWriter.java | 11 ----------- ...ContentSecurityPolicyServerHttpHeadersWriter.java | 11 ----------- .../ContentSecurityPolicyHeaderWriterTests.java | 12 ------------ ...ntSecurityPolicyServerHttpHeadersWriterTests.java | 12 ------------ 4 files changed, 46 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index e5a7b0f332c..544e1dd63da 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -194,17 +194,6 @@ public void setReportOnly(boolean reportOnly) { this.reportOnly = reportOnly; } - /** - * Returns whether the content security policy is nonce-based. The CSP is considered - * nonce-based if the configured {@code policyDirectives} string contains a - * {@code {nonce}} placeholder. - * @return whether the content security policy is nonce-based - * @since 7.1 - */ - public boolean isNonceBased() { - return this.isNonceBased; - } - @Override public String toString() { return getClass().getName() + " [policyDirectives=" + this.policyDirectives + "; reportOnly=" + this.reportOnly diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java index 10b608eb6ed..0a51f781314 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -120,17 +120,6 @@ public void setReportOnly(boolean reportOnly) { this.reportOnly = reportOnly; } - /** - * Returns whether the content security policy is nonce-based. The CSP is considered - * nonce-based if the configured {@code policyDirectives} string contains a - * {@code {nonce}} placeholder. - * @return whether the content security policy is nonce-based - * @since 7.1 - */ - public boolean isNonceBased() { - return this.isNonceBased; - } - private static String resolveHeader(boolean reportOnly) { return reportOnly ? CONTENT_SECURITY_POLICY_REPORT_ONLY : CONTENT_SECURITY_POLICY; } diff --git a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java index d7863faa62b..7def86bee67 100644 --- a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java @@ -131,18 +131,6 @@ public void writeContentSecurityPolicyReportOnlyHeaderWhenNotPresent() { assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER)).isSameAs(value); } - @Test - public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() { - this.writer.setPolicyDirectives("script-src 'self' 'nonce-{nonce}'"); - assertThat(this.writer.isNonceBased()).isTrue(); - this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - assertThat(this.writer.isNonceBased()).isTrue(); - this.writer.setPolicyDirectives(DEFAULT_POLICY_DIRECTIVES); - assertThat(this.writer.isNonceBased()).isFalse(); - this.writer.setPolicyDirectives("script-src 'self' 'sha256-A/nonce/without/braces/is/not/a/placeholder='"); - assertThat(this.writer.isNonceBased()).isFalse(); - } - @Test public void writeNonceBasedCspWhenNoncePresent() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java index e8e9330fbce..43bcd3b3aef 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java @@ -96,18 +96,6 @@ public void writeHeadersWhenAlreadyWrittenThenDoesNotOverride() { assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)).containsOnly(headerValue); } - @Test - public void whenPolicyDirectivesContainNoncePlaceholderThenWriterIsNonceBased() { - this.writer.setPolicyDirectives("script-src 'self' 'nonce-{nonce}'"); - assertThat(this.writer.isNonceBased()).isTrue(); - this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - assertThat(this.writer.isNonceBased()).isTrue(); - this.writer.setPolicyDirectives(DEFAULT_POLICY_DIRECTIVES); - assertThat(this.writer.isNonceBased()).isFalse(); - this.writer.setPolicyDirectives("script-src 'self' 'sha256-A/nonce/without/braces/is/not/a/placeholder='"); - assertThat(this.writer.isNonceBased()).isFalse(); - } - @Test public void writeNonceBasedCspWhenNoncePresent() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); From f666e444f8a28c05a6fd3c6ac2f11e27df521c3f Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 3 Apr 2026 19:55:02 +0800 Subject: [PATCH 13/26] Avoid tying CSP {nonce} placeholder to specific stacks Signed-off-by: Ziqin Wang --- .../web/header/writers/ContentSecurityPolicyHeaderWriter.java | 2 +- .../header/ContentSecurityPolicyServerHttpHeadersWriter.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index 544e1dd63da..855ecc4350b 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -120,7 +120,7 @@ public final class ContentSecurityPolicyHeaderWriter implements HeaderWriter { private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'"; - public static final String NONCE_PLACEHOLDER = "{nonce}"; + private static final String NONCE_PLACEHOLDER = "{nonce}"; private String policyDirectives; diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java index 0a51f781314..a8e46753b28 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -61,7 +61,7 @@ public final class ContentSecurityPolicyServerHttpHeadersWriter implements Serve public static final String CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only"; - public static final String NONCE_PLACEHOLDER = "{nonce}"; + private static final String NONCE_PLACEHOLDER = "{nonce}"; private @Nullable String policyDirectives; From 11279f95275c1f07083c714a081edceb7523ae70 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Sun, 5 Apr 2026 00:31:45 +0800 Subject: [PATCH 14/26] Deprecate ContentSecurityPolicySpec#reportOnly(boolean) Replaced by `ContentSecurityPolicySpec#reportOnly()`. The return type is changed to ContentSecurityPolicySpec to allow method chaining in lambda DSL. Signed-off-by: Ziqin Wang --- .../config/web/server/ServerHttpSecurity.java | 17 ++++++++++++++++- .../server/ServerContentSecurityPolicyDsl.kt | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 83a19dcad62..78abe8fec0d 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2862,12 +2862,27 @@ private ContentSecurityPolicySpec() { * header. * @param reportOnly whether to only report policy violations * @return the {@link HeaderSpec} to continue configuring + * @deprecated Use {@link #reportOnly()} instead */ + @Deprecated(since = "7.1") public HeaderSpec reportOnly(boolean reportOnly) { - this.writer.setReportOnly(reportOnly); + if (reportOnly) { + this.reportOnly(); + } return HeaderSpec.this; } + /** + * Enables (includes) the {@code Content-Security-Policy-Report-Only} header + * in the response. Otherwise, defaults to the {@code Content-Security-Policy} + * header. + * @return the {@link ContentSecurityPolicySpec} to continue configuring + */ + public ContentSecurityPolicySpec reportOnly() { + this.writer.setReportOnly(true); + return this; + } + /** * Sets the security policy directive(s) to be used in the response header. * The {@code policyDirectives} may contain {@code {nonce}} as placeholders diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt index e017d5edb75..eb05a5c1e21 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt @@ -76,8 +76,8 @@ class ServerContentSecurityPolicyDsl { policyDirectives?.also { contentSecurityPolicy.policyDirectives(policyDirectives) } - reportOnly?.also { - contentSecurityPolicy.reportOnly(reportOnly!!) + if (reportOnly == true) { + contentSecurityPolicy.reportOnly() } nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName) exchangeMatcher?.also(contentSecurityPolicy::exchangeMatcher) From 32a8249b3ce594d6db26b017a42cc3c46285ed05 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Sun, 5 Apr 2026 01:09:06 +0800 Subject: [PATCH 15/26] Deprecate ContentSecurityPolicySpec#policyDirectives(String) Replaced by `ContentSecurityPolicySpec#directives(String)`. The return type is changed to ContentSecurityPolicySpec to allow method chaining in lambda DSL. Signed-off-by: Ziqin Wang --- .../config/web/server/ServerHttpSecurity.java | 17 ++++++++++++++++- .../server/ServerContentSecurityPolicyDsl.kt | 4 +--- .../config/web/server/HeaderSpecTests.java | 18 +++++++++--------- .../CustomizerBeanOrderingConfiguration.java | 2 +- ...ttpSecurityCustomizerBeanConfiguration.java | 2 +- .../TopLevelCustomizerBeanConfiguration.java | 2 +- .../CustomizerBeanOrderingConfiguration.kt | 6 +----- ...rHttpSecurityCustomizerBeanConfiguration.kt | 2 +- .../TopLevelCustomizerBeanConfiguration.kt | 2 +- 9 files changed, 32 insertions(+), 23 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 78abe8fec0d..401d6550453 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2890,12 +2890,27 @@ public ContentSecurityPolicySpec reportOnly() { * 'nonce-{nonce}'}. * @param policyDirectives the security policy directive(s) * @return the {@link HeaderSpec} to continue configuring + * @deprecated Use {@link #directives(String)} instead */ + @Deprecated(since = "7.1") public HeaderSpec policyDirectives(String policyDirectives) { - this.writer.setPolicyDirectives(policyDirectives); + this.directives(policyDirectives); return HeaderSpec.this; } + /** + * Sets the security policy directive(s) to be used in the response header. + * The {@code policyDirectives} may contain {@code {nonce}} as placeholders + * for a generated secure random nonce, e.g., {@code script-src 'self' + * 'nonce-{nonce}'}. + * @param policyDirectives the security policy directive(s) + * @return the {@link ContentSecurityPolicySpec} to continue configuring + */ + public ContentSecurityPolicySpec directives(String policyDirectives) { + this.writer.setPolicyDirectives(policyDirectives); + return this; + } + /** * Sets the name of the {@link ServerWebExchange#getAttribute(String) exchange * attribute} for the generated nonce. Views can read this attribute to render diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt index eb05a5c1e21..eb82a8822ce 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt @@ -73,9 +73,7 @@ class ServerContentSecurityPolicyDsl { internal fun get(): (ServerHttpSecurity.HeaderSpec.ContentSecurityPolicySpec) -> Unit { return { contentSecurityPolicy -> - policyDirectives?.also { - contentSecurityPolicy.policyDirectives(policyDirectives) - } + policyDirectives?.also(contentSecurityPolicy::directives) if (reportOnly == true) { contentSecurityPolicy.reportOnly() } diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index 81befac26ad..b3ef18fb454 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -368,7 +368,7 @@ public void headersWhenContentSecurityPolicyEnabledThenFeaturePolicyWritten() { policyDirectives); // @formatter:off this.http.headers((headers) -> headers - .contentSecurityPolicy((csp) -> csp.policyDirectives(policyDirectives))); + .contentSecurityPolicy((csp) -> csp.directives(policyDirectives))); // @formatter:on assertHeaders(); } @@ -393,9 +393,9 @@ public void headersWhenContentSecurityPolicyEnabledInLambdaThenContentSecurityPo policyDirectives); // @formatter:off this.http.headers((headers) -> headers - .contentSecurityPolicy((csp) -> csp - .policyDirectives(policyDirectives) - ) + .contentSecurityPolicy((csp) -> csp + .directives(policyDirectives) + ) ); // @formatter:on assertHeaders(); @@ -409,7 +409,7 @@ public void headersWhenContentSecurityPolicyEnabledWithDefaultNonceThenHeaderMat // @formatter:off this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp - .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); + .directives("script-src 'self' 'nonce-{nonce}'"))); // @formatter:on WebTestClient client = WebTestClientBuilder .bindToControllerAndWebFilters(ReactiveTestCspNonceController.class, this.http.build()) @@ -437,7 +437,7 @@ public void headersWhenContentSecurityPolicyEnabledWithCustomNonceThenHeaderMatc this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp .nonceAttributeName("CUSTOM_NONCE") - .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); + .directives("script-src 'self' 'nonce-{nonce}'"))); // @formatter:on WebTestClient client = WebTestClientBuilder .bindToControllerAndWebFilters(ReactiveTestCspNonceController.class, this.http.build()) @@ -467,7 +467,7 @@ public void headersWhenContentSecurityPolicyEnabledWithMatcherThenHeaderInRespon (MediaType.TEXT_HTML.isPresentIn(exchange.getRequest().getHeaders().getAccept()) ? ServerWebExchangeMatcher.MatchResult.match() : ServerWebExchangeMatcher.MatchResult.notMatch())) - .policyDirectives(policyDirectives))); + .directives(policyDirectives))); // @formatter:on WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); // @formatter:off @@ -492,7 +492,7 @@ public void headersWhenContentSecurityPolicyEnabledWithPathMatchersThenHeaderInR this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp .exchangeMatchers("/foo/**", "/bar/**") - .policyDirectives(policyDirectives))); + .directives(policyDirectives))); // @formatter:on WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); // @formatter:off @@ -520,7 +520,7 @@ public void headersWhenContentSecurityPolicyWithOverriddenMatchersThenFailToConf .contentSecurityPolicy((csp) -> csp .exchangeMatcher(ServerWebExchangeMatchers.anyExchange()) .exchangeMatchers("/**") - .policyDirectives("default-src 'self'")))) + .directives("default-src 'self'")))) .withMessage("ExchangeMatcher(s) is already configured"); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java index 6df62c8e227..e740906cace 100644 --- a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java @@ -75,7 +75,7 @@ Customizer contentSecurityPolicy() { // @formatter:off return (headers) -> headers .contentSecurityPolicy((csp) -> csp - .policyDirectives("object-src 'none'") + .directives("object-src 'none'") ); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java index 7fde90bd5f6..8025054d6d1 100644 --- a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java @@ -49,7 +49,7 @@ Customizer httpSecurityCustomizer() { .headers((headers) -> headers .contentSecurityPolicy((csp) -> csp // <1> - .policyDirectives("object-src 'none'") + .directives("object-src 'none'") ) ) // <2> diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java index e516fda8c9b..5144b75c894 100644 --- a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java @@ -48,7 +48,7 @@ Customizer headersSecurity() { return (headers) -> headers .contentSecurityPolicy((csp) -> csp // <1> - .policyDirectives("object-src 'none'") + .directives("object-src 'none'") ); // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt index 87cf9c3ffb9..8f06432b572 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt @@ -22,13 +22,9 @@ import org.springframework.core.Ordered import org.springframework.core.annotation.Order import org.springframework.security.config.Customizer import org.springframework.security.config.ThrowingCustomizer -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer -import org.springframework.security.config.annotation.web.configurers.HttpsRedirectConfigurer import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.web.server.SecurityWebFilterChain -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.anyExchange /** * @@ -79,7 +75,7 @@ internal class CustomizerBeanOrderingConfiguration { // @formatter:off return Customizer { headers -> headers .contentSecurityPolicy { csp -> csp - .policyDirectives("object-src 'none'") + .directives("object-src 'none'") } } // @formatter:on diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt index d19f3f284c9..a00580bf50a 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt @@ -31,7 +31,7 @@ class ServerHttpSecurityCustomizerBeanConfiguration { .headers { headers -> headers .contentSecurityPolicy { csp -> csp // <1> - .policyDirectives("object-src 'none'") + .directives("object-src 'none'") } } // <2> diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt index 77c16a7d07e..1177cf0981c 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt @@ -33,7 +33,7 @@ class TopLevelCustomizerBeanConfiguration { return Customizer { headers -> headers .contentSecurityPolicy { csp -> csp // <1> - .policyDirectives("object-src 'none'") + .directives("object-src 'none'") } } // @formatter:on From 0fbc6767bfd325f66f24bacedb20cdf7bfc3d261 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Thu, 3 Sep 2026 14:20:23 +0800 Subject: [PATCH 16/26] Revert deprecation about method chaining in ContentSecurityPolicySpec This reverts commit 11279f95275c1f07083c714a081edceb7523ae70. This reverts commit 32a8249b3ce594d6db26b017a42cc3c46285ed05. The problem may be addressed in a separate PR. Signed-off-by: Ziqin Wang --- .../config/web/server/ServerHttpSecurity.java | 34 ++----------------- .../server/ServerContentSecurityPolicyDsl.kt | 8 +++-- .../config/web/server/HeaderSpecTests.java | 18 +++++----- .../CustomizerBeanOrderingConfiguration.java | 2 +- ...tpSecurityCustomizerBeanConfiguration.java | 2 +- .../TopLevelCustomizerBeanConfiguration.java | 2 +- .../CustomizerBeanOrderingConfiguration.kt | 6 +++- ...HttpSecurityCustomizerBeanConfiguration.kt | 2 +- .../TopLevelCustomizerBeanConfiguration.kt | 2 +- 9 files changed, 26 insertions(+), 50 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 401d6550453..83a19dcad62 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2862,27 +2862,12 @@ private ContentSecurityPolicySpec() { * header. * @param reportOnly whether to only report policy violations * @return the {@link HeaderSpec} to continue configuring - * @deprecated Use {@link #reportOnly()} instead */ - @Deprecated(since = "7.1") public HeaderSpec reportOnly(boolean reportOnly) { - if (reportOnly) { - this.reportOnly(); - } + this.writer.setReportOnly(reportOnly); return HeaderSpec.this; } - /** - * Enables (includes) the {@code Content-Security-Policy-Report-Only} header - * in the response. Otherwise, defaults to the {@code Content-Security-Policy} - * header. - * @return the {@link ContentSecurityPolicySpec} to continue configuring - */ - public ContentSecurityPolicySpec reportOnly() { - this.writer.setReportOnly(true); - return this; - } - /** * Sets the security policy directive(s) to be used in the response header. * The {@code policyDirectives} may contain {@code {nonce}} as placeholders @@ -2890,25 +2875,10 @@ public ContentSecurityPolicySpec reportOnly() { * 'nonce-{nonce}'}. * @param policyDirectives the security policy directive(s) * @return the {@link HeaderSpec} to continue configuring - * @deprecated Use {@link #directives(String)} instead */ - @Deprecated(since = "7.1") public HeaderSpec policyDirectives(String policyDirectives) { - this.directives(policyDirectives); - return HeaderSpec.this; - } - - /** - * Sets the security policy directive(s) to be used in the response header. - * The {@code policyDirectives} may contain {@code {nonce}} as placeholders - * for a generated secure random nonce, e.g., {@code script-src 'self' - * 'nonce-{nonce}'}. - * @param policyDirectives the security policy directive(s) - * @return the {@link ContentSecurityPolicySpec} to continue configuring - */ - public ContentSecurityPolicySpec directives(String policyDirectives) { this.writer.setPolicyDirectives(policyDirectives); - return this; + return HeaderSpec.this; } /** diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt index eb82a8822ce..e017d5edb75 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt @@ -73,9 +73,11 @@ class ServerContentSecurityPolicyDsl { internal fun get(): (ServerHttpSecurity.HeaderSpec.ContentSecurityPolicySpec) -> Unit { return { contentSecurityPolicy -> - policyDirectives?.also(contentSecurityPolicy::directives) - if (reportOnly == true) { - contentSecurityPolicy.reportOnly() + policyDirectives?.also { + contentSecurityPolicy.policyDirectives(policyDirectives) + } + reportOnly?.also { + contentSecurityPolicy.reportOnly(reportOnly!!) } nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName) exchangeMatcher?.also(contentSecurityPolicy::exchangeMatcher) diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index b3ef18fb454..81befac26ad 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -368,7 +368,7 @@ public void headersWhenContentSecurityPolicyEnabledThenFeaturePolicyWritten() { policyDirectives); // @formatter:off this.http.headers((headers) -> headers - .contentSecurityPolicy((csp) -> csp.directives(policyDirectives))); + .contentSecurityPolicy((csp) -> csp.policyDirectives(policyDirectives))); // @formatter:on assertHeaders(); } @@ -393,9 +393,9 @@ public void headersWhenContentSecurityPolicyEnabledInLambdaThenContentSecurityPo policyDirectives); // @formatter:off this.http.headers((headers) -> headers - .contentSecurityPolicy((csp) -> csp - .directives(policyDirectives) - ) + .contentSecurityPolicy((csp) -> csp + .policyDirectives(policyDirectives) + ) ); // @formatter:on assertHeaders(); @@ -409,7 +409,7 @@ public void headersWhenContentSecurityPolicyEnabledWithDefaultNonceThenHeaderMat // @formatter:off this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp - .directives("script-src 'self' 'nonce-{nonce}'"))); + .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); // @formatter:on WebTestClient client = WebTestClientBuilder .bindToControllerAndWebFilters(ReactiveTestCspNonceController.class, this.http.build()) @@ -437,7 +437,7 @@ public void headersWhenContentSecurityPolicyEnabledWithCustomNonceThenHeaderMatc this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp .nonceAttributeName("CUSTOM_NONCE") - .directives("script-src 'self' 'nonce-{nonce}'"))); + .policyDirectives("script-src 'self' 'nonce-{nonce}'"))); // @formatter:on WebTestClient client = WebTestClientBuilder .bindToControllerAndWebFilters(ReactiveTestCspNonceController.class, this.http.build()) @@ -467,7 +467,7 @@ public void headersWhenContentSecurityPolicyEnabledWithMatcherThenHeaderInRespon (MediaType.TEXT_HTML.isPresentIn(exchange.getRequest().getHeaders().getAccept()) ? ServerWebExchangeMatcher.MatchResult.match() : ServerWebExchangeMatcher.MatchResult.notMatch())) - .directives(policyDirectives))); + .policyDirectives(policyDirectives))); // @formatter:on WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); // @formatter:off @@ -492,7 +492,7 @@ public void headersWhenContentSecurityPolicyEnabledWithPathMatchersThenHeaderInR this.http.headers((headers) -> headers .contentSecurityPolicy((csp) -> csp .exchangeMatchers("/foo/**", "/bar/**") - .directives(policyDirectives))); + .policyDirectives(policyDirectives))); // @formatter:on WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build(); // @formatter:off @@ -520,7 +520,7 @@ public void headersWhenContentSecurityPolicyWithOverriddenMatchersThenFailToConf .contentSecurityPolicy((csp) -> csp .exchangeMatcher(ServerWebExchangeMatchers.anyExchange()) .exchangeMatchers("/**") - .directives("default-src 'self'")))) + .policyDirectives("default-src 'self'")))) .withMessage("ExchangeMatcher(s) is already configured"); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java index e740906cace..6df62c8e227 100644 --- a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.java @@ -75,7 +75,7 @@ Customizer contentSecurityPolicy() { // @formatter:off return (headers) -> headers .contentSecurityPolicy((csp) -> csp - .directives("object-src 'none'") + .policyDirectives("object-src 'none'") ); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java index 8025054d6d1..7fde90bd5f6 100644 --- a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.java @@ -49,7 +49,7 @@ Customizer httpSecurityCustomizer() { .headers((headers) -> headers .contentSecurityPolicy((csp) -> csp // <1> - .directives("object-src 'none'") + .policyDirectives("object-src 'none'") ) ) // <2> diff --git a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java index 5144b75c894..e516fda8c9b 100644 --- a/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java +++ b/docs/src/test/java/org/springframework/security/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.java @@ -48,7 +48,7 @@ Customizer headersSecurity() { return (headers) -> headers .contentSecurityPolicy((csp) -> csp // <1> - .directives("object-src 'none'") + .policyDirectives("object-src 'none'") ); // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt index 8f06432b572..87cf9c3ffb9 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/customizerbeanordering/CustomizerBeanOrderingConfiguration.kt @@ -22,9 +22,13 @@ import org.springframework.core.Ordered import org.springframework.core.annotation.Order import org.springframework.security.config.Customizer import org.springframework.security.config.ThrowingCustomizer +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer +import org.springframework.security.config.annotation.web.configurers.HttpsRedirectConfigurer import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.anyExchange /** * @@ -75,7 +79,7 @@ internal class CustomizerBeanOrderingConfiguration { // @formatter:off return Customizer { headers -> headers .contentSecurityPolicy { csp -> csp - .directives("object-src 'none'") + .policyDirectives("object-src 'none'") } } // @formatter:on diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt index a00580bf50a..d19f3f284c9 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/serverhttpsecuritycustomizerbean/ServerHttpSecurityCustomizerBeanConfiguration.kt @@ -31,7 +31,7 @@ class ServerHttpSecurityCustomizerBeanConfiguration { .headers { headers -> headers .contentSecurityPolicy { csp -> csp // <1> - .directives("object-src 'none'") + .policyDirectives("object-src 'none'") } } // <2> diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt index 1177cf0981c..77c16a7d07e 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/configuration/toplevelcustomizerbean/TopLevelCustomizerBeanConfiguration.kt @@ -33,7 +33,7 @@ class TopLevelCustomizerBeanConfiguration { return Customizer { headers -> headers .contentSecurityPolicy { csp -> csp // <1> - .directives("object-src 'none'") + .policyDirectives("object-src 'none'") } } // @formatter:on From 6c1fbf343c7844e84750a8dea786c00c8061e129 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 4 Sep 2026 11:16:00 +0800 Subject: [PATCH 17/26] Move request matcher to CSP header writer and improve config Signed-off-by: Ziqin Wang --- .../web/configurers/HeadersConfigurer.java | 29 +++----- .../config/web/server/ServerHttpSecurity.java | 47 +++++-------- .../ContentSecurityPolicyHeaderWriter.java | 24 ++++++- ...SecurityPolicyServerHttpHeadersWriter.java | 66 ++++++++++++------- 4 files changed, 89 insertions(+), 77 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java index 1bca0df9c01..90b692cfbb4 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java @@ -38,7 +38,6 @@ import org.springframework.security.web.header.writers.CrossOriginEmbedderPolicyHeaderWriter; import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter; import org.springframework.security.web.header.writers.CrossOriginResourcePolicyHeaderWriter; -import org.springframework.security.web.header.writers.DelegatingRequestMatcherHeaderWriter; import org.springframework.security.web.header.writers.FeaturePolicyHeaderWriter; import org.springframework.security.web.header.writers.HpkpHeaderWriter; import org.springframework.security.web.header.writers.HstsHeaderWriter; @@ -253,6 +252,7 @@ public HeadersConfigurer httpPublicKeyPinning(Customizer hpkpCust public HeadersConfigurer contentSecurityPolicy( Customizer contentSecurityCustomizer) { this.contentSecurityPolicy.writer = new ContentSecurityPolicyHeaderWriter(); + this.contentSecurityPolicy.nonceGeneratingFilter = new ContentSecurityPolicyNonceGeneratingFilter(); contentSecurityCustomizer.customize(this.contentSecurityPolicy); return HeadersConfigurer.this; } @@ -280,7 +280,10 @@ public HeadersConfigurer defaultsDisabled() { public void configure(H http) { HeaderWriterFilter headersFilter = createHeaderWriterFilter(); http.addFilter(headersFilter); - http.addFilterBefore(this.contentSecurityPolicy.getNonceGeneratingFilter(), HeaderWriterFilter.class); + // nonceGeneratingFilter is instantiated iff CSP is configured + if (this.contentSecurityPolicy.nonceGeneratingFilter != null) { + http.addFilterBefore(this.contentSecurityPolicy.nonceGeneratingFilter, HeaderWriterFilter.class); + } } /** @@ -310,7 +313,7 @@ private List getHeaderWriters() { addIfNotNull(writers, this.hsts.writer); addIfNotNull(writers, this.frameOptions.writer); addIfNotNull(writers, this.hpkp.writer); - addIfNotNull(writers, this.contentSecurityPolicy.getWriter()); + addIfNotNull(writers, this.contentSecurityPolicy.writer); addIfNotNull(writers, this.referrerPolicy.writer); addIfNotNull(writers, this.featurePolicy.writer); addIfNotNull(writers, this.permissionsPolicy.writer); @@ -945,7 +948,7 @@ public final class ContentSecurityPolicyConfig { private ContentSecurityPolicyHeaderWriter writer; - private @Nullable String nonceAttributeName; + private ContentSecurityPolicyNonceGeneratingFilter nonceGeneratingFilter; private @Nullable RequestMatcher requestMatcher; @@ -986,7 +989,7 @@ public ContentSecurityPolicyConfig reportOnly() { */ public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) { Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); - this.nonceAttributeName = nonceAttributeName; + this.nonceGeneratingFilter.setAttributeName(nonceAttributeName); return this; } @@ -1005,6 +1008,7 @@ public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) public ContentSecurityPolicyConfig requestMatcher(RequestMatcher requestMatcher) { Assert.notNull(requestMatcher, "RequestMatcher cannot be null"); Assert.state(this.requestMatcher == null, "RequestMatcher(s) is already configured"); + this.writer.setRequestMatcher(requestMatcher); this.requestMatcher = requestMatcher; return this; } @@ -1029,21 +1033,6 @@ public ContentSecurityPolicyConfig requestMatchers(String... pathPatterns) { return this.requestMatcher(matcher); } - HeaderWriter getWriter() { - if (this.requestMatcher != null) { - return new DelegatingRequestMatcherHeaderWriter(this.requestMatcher, this.writer); - } - return this.writer; - } - - ContentSecurityPolicyNonceGeneratingFilter getNonceGeneratingFilter() { - var filter = new ContentSecurityPolicyNonceGeneratingFilter(); - if (this.nonceAttributeName != null) { - filter.setAttributeName(this.nonceAttributeName); - } - return filter; - } - } public final class ReferrerPolicyConfig { diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 83a19dcad62..d5f2c7a294d 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -200,7 +200,6 @@ import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy; import org.springframework.security.web.server.header.ServerHttpHeadersWriter; -import org.springframework.security.web.server.header.ServerWebExchangeDelegatingServerHttpHeadersWriter; import org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter; import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter; import org.springframework.security.web.server.header.XXssProtectionServerHttpHeadersWriter; @@ -2476,7 +2475,7 @@ public final class HeaderSpec { private PermissionsPolicyServerHttpHeadersWriter permissionsPolicy = new PermissionsPolicyServerHttpHeadersWriter(); - private ContentSecurityPolicySpec contentSecurityPolicy = new ContentSecurityPolicySpec(); + private ContentSecurityPolicyServerHttpHeadersWriter contentSecurityPolicy = new ContentSecurityPolicyServerHttpHeadersWriter(); private ReferrerPolicyServerHttpHeadersWriter referrerPolicy = new ReferrerPolicyServerHttpHeadersWriter(); @@ -2488,6 +2487,8 @@ public final class HeaderSpec { private List customHeadersWriters = new ArrayList<>(); + private ContentSecurityPolicyNonceGeneratingWebFilter nonceGeneratingFilter; + private HeaderSpec() { } @@ -2560,16 +2561,17 @@ public HeaderSpec hsts(Customizer hstsCustomizer) { protected void configure(ServerHttpSecurity http) { Stream builtInWriters = Stream .of(this.cacheControl, this.contentTypeOptions, this.hsts, this.frameOptions, this.xss, - this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy.getWriter(), - this.referrerPolicy, this.crossOriginOpenerPolicy, this.crossOriginEmbedderPolicy, - this.crossOriginResourcePolicy) + this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy, this.referrerPolicy, + this.crossOriginOpenerPolicy, this.crossOriginEmbedderPolicy, this.crossOriginResourcePolicy) .filter(Objects::nonNull); ServerHttpHeadersWriter writer = new CompositeServerHttpHeadersWriter( Stream.concat(builtInWriters, this.customHeadersWriters.stream()).toList()); HttpHeaderWriterWebFilter result = new HttpHeaderWriterWebFilter(writer); http.addFilterAt(result, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); - http.addFilterBefore(this.contentSecurityPolicy.getNonceGeneratingFilter(), - SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); + // nonceGeneratingFilter is instantiated iff CSP is configured + if (this.nonceGeneratingFilter != null) { + http.addFilterBefore(this.nonceGeneratingFilter, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); + } } /** @@ -2590,7 +2592,7 @@ public HeaderSpec xssProtection(Customizer xssProtectionCusto * @return the {@link HeaderSpec} to customize */ public HeaderSpec contentSecurityPolicy(Customizer contentSecurityPolicyCustomizer) { - contentSecurityPolicyCustomizer.customize(this.contentSecurityPolicy); + contentSecurityPolicyCustomizer.customize(new ContentSecurityPolicySpec()); return this; } @@ -2846,14 +2848,11 @@ public final class ContentSecurityPolicySpec { private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'"; - private final ContentSecurityPolicyServerHttpHeadersWriter writer = new ContentSecurityPolicyServerHttpHeadersWriter(); - - private @Nullable String nonceAttributeName; - private @Nullable ServerWebExchangeMatcher exchangeMatcher; private ContentSecurityPolicySpec() { - this.writer.setPolicyDirectives(DEFAULT_SRC_SELF_POLICY); + HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(DEFAULT_SRC_SELF_POLICY); + HeaderSpec.this.nonceGeneratingFilter = new ContentSecurityPolicyNonceGeneratingWebFilter(); } /** @@ -2864,7 +2863,7 @@ private ContentSecurityPolicySpec() { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec reportOnly(boolean reportOnly) { - this.writer.setReportOnly(reportOnly); + HeaderSpec.this.contentSecurityPolicy.setReportOnly(reportOnly); return HeaderSpec.this; } @@ -2877,7 +2876,7 @@ public HeaderSpec reportOnly(boolean reportOnly) { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec policyDirectives(String policyDirectives) { - this.writer.setPolicyDirectives(policyDirectives); + HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); return HeaderSpec.this; } @@ -2893,7 +2892,7 @@ public HeaderSpec policyDirectives(String policyDirectives) { */ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); - this.nonceAttributeName = nonceAttributeName; + HeaderSpec.this.nonceGeneratingFilter.setAttributeName(nonceAttributeName); return this; } @@ -2913,6 +2912,7 @@ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { public ContentSecurityPolicySpec exchangeMatcher(ServerWebExchangeMatcher matcher) { Assert.notNull(matcher, "Matcher must not be null"); Assert.state(this.exchangeMatcher == null, "ExchangeMatcher(s) is already configured"); + HeaderSpec.this.contentSecurityPolicy.setExchangeMatcher(matcher); this.exchangeMatcher = matcher; return this; } @@ -2936,21 +2936,6 @@ public ContentSecurityPolicySpec exchangeMatchers(String... pathPatterns) { return this.exchangeMatcher(ServerWebExchangeMatchers.pathMatchers(pathPatterns)); } - ServerHttpHeadersWriter getWriter() { - if (this.exchangeMatcher != null) { - return new ServerWebExchangeDelegatingServerHttpHeadersWriter(this.exchangeMatcher, this.writer); - } - return this.writer; - } - - ContentSecurityPolicyNonceGeneratingWebFilter getNonceGeneratingFilter() { - var filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); - if (this.nonceAttributeName != null) { - filter.setAttributeName(this.nonceAttributeName); - } - return filter; - } - } /** diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index 855ecc4350b..b07db14fd8b 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -23,6 +23,8 @@ import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; import org.springframework.security.web.header.HeaderWriter; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; /** @@ -122,6 +124,8 @@ public final class ContentSecurityPolicyHeaderWriter implements HeaderWriter { private static final String NONCE_PLACEHOLDER = "{nonce}"; + private RequestMatcher requestMatcher = AnyRequestMatcher.INSTANCE; + private String policyDirectives; private boolean reportOnly; @@ -153,6 +157,9 @@ public ContentSecurityPolicyHeaderWriter(String policyDirectives) { @Override @SuppressWarnings("unchecked") public void writeHeaders(HttpServletRequest request, HttpServletResponse response) { + if (!this.requestMatcher.matches(request)) { + return; + } String headerName = (!this.reportOnly) ? CONTENT_SECURITY_POLICY_HEADER : CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER; if (!response.containsHeader(headerName)) { @@ -172,6 +179,18 @@ public void writeHeaders(HttpServletRequest request, HttpServletResponse respons } } + /** + * Sets the {@link RequestMatcher} which determines whether CSP should be written. The + * default is to write CSP unconditionally. + * @param requestMatcher the {@link RequestMatcher} to use + * @throws IllegalArgumentException if {@code requestMatcher} is null + * @since 7.2 + */ + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + /** * Sets the security policy directive(s) to be used in the response header. The * {@code policyDirectives} may contain {@code {nonce}} as placeholders to be @@ -196,8 +215,9 @@ public void setReportOnly(boolean reportOnly) { @Override public String toString() { - return getClass().getName() + " [policyDirectives=" + this.policyDirectives + "; reportOnly=" + this.reportOnly - + "; isNonceBased=" + this.isNonceBased + "]"; + return getClass().getName() + " [requestMatcher=" + this.requestMatcher + "; policyDirectives=" + + this.policyDirectives + "; reportOnly=" + this.reportOnly + "; isNonceBased=" + this.isNonceBased + + "]"; } } diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java index a8e46753b28..71afed6ec4e 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -22,6 +22,8 @@ import reactor.core.publisher.Mono; import org.springframework.http.HttpHeaders; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; @@ -63,6 +65,8 @@ public final class ContentSecurityPolicyServerHttpHeadersWriter implements Serve private static final String NONCE_PLACEHOLDER = "{nonce}"; + private ServerWebExchangeMatcher exchangeMatcher = ServerWebExchangeMatchers.anyExchange(); + private @Nullable String policyDirectives; private boolean reportOnly; @@ -71,31 +75,45 @@ public final class ContentSecurityPolicyServerHttpHeadersWriter implements Serve @Override public Mono writeHttpHeaders(ServerWebExchange exchange) { - return Mono.justOrEmpty(this.policyDirectives).flatMap((csp) -> { - String headerName = resolveHeader(this.reportOnly); - HttpHeaders headers = exchange.getResponse().getHeaders(); - - if (headers.containsHeader(headerName)) { - return Mono.empty(); - } - - if (!this.isNonceBased) { - headers.put(headerName, List.of(csp)); - return Mono.empty(); - } - - Mono deferredNonce = exchange - .getAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); - if (deferredNonce == null) { - return Mono.error(new IllegalStateException( - "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " - + ContentSecurityPolicyNonceGeneratingWebFilter.class.getName())); - } - return deferredNonce.flatMap((nonce) -> { - headers.put(headerName, List.of(csp.replace(NONCE_PLACEHOLDER, nonce))); - return Mono.empty(); + return Mono.justOrEmpty(this.policyDirectives) + .filterWhen((ignored) -> this.exchangeMatcher.matches(exchange) + .map(ServerWebExchangeMatcher.MatchResult::isMatch)) + .flatMap((csp) -> { + String headerName = resolveHeader(this.reportOnly); + HttpHeaders headers = exchange.getResponse().getHeaders(); + + if (headers.containsHeader(headerName)) { + return Mono.empty(); + } + + if (!this.isNonceBased) { + headers.put(headerName, List.of(csp)); + return Mono.empty(); + } + + Mono deferredNonce = exchange + .getAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); + if (deferredNonce == null) { + return Mono.error(new IllegalStateException( + "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " + + ContentSecurityPolicyNonceGeneratingWebFilter.class.getName())); + } + return deferredNonce + .doOnNext((nonce) -> headers.put(headerName, List.of(csp.replace(NONCE_PLACEHOLDER, nonce)))) + .then(); }); - }); + } + + /** + * Sets the {@link ServerWebExchangeMatcher} which determines whether CSP should be + * written. The default is to write CSP unconditionally. + * @param exchangeMatcher the {@link ServerWebExchangeMatcher} to use + * @throws IllegalArgumentException if {@code exchangeMatcher} is null + * @since 7.2 + */ + public void setExchangeMatcher(ServerWebExchangeMatcher exchangeMatcher) { + Assert.notNull(exchangeMatcher, "exchangeMatcher cannot be null"); + this.exchangeMatcher = exchangeMatcher; } /** From 15a761e491e5eea77c5224162a0861cc9a01ac19 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 4 Sep 2026 12:27:30 +0800 Subject: [PATCH 18/26] Undo changes about header writer disabling and composition Now that ServerWebExchangeMatcher is moved into ContentSecurityPolicyServerHttpHeadersWriter, the CSP header writer could be eagerly constructed and placed in HeaderSpec directly, just like other header writers. Thus, changes about header writer disabling and composition introduced in acf956ae4e95b5f8d45b6076f9cad306ff055a3e are no longer necessary. Signed-off-by: Ziqin Wang --- .../config/web/server/ServerHttpSecurity.java | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index d5f2c7a294d..9d4176892a5 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -34,7 +34,6 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.Stream; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -2461,6 +2460,8 @@ protected void configure(ServerHttpSecurity http) { */ public final class HeaderSpec { + private final List writers; + private CacheControlServerHttpHeadersWriter cacheControl = new CacheControlServerHttpHeadersWriter(); private ContentTypeOptionsServerHttpHeadersWriter contentTypeOptions = new ContentTypeOptionsServerHttpHeadersWriter(); @@ -2485,11 +2486,13 @@ public final class HeaderSpec { private CrossOriginResourcePolicyServerHttpHeadersWriter crossOriginResourcePolicy = new CrossOriginResourcePolicyServerHttpHeadersWriter(); - private List customHeadersWriters = new ArrayList<>(); - private ContentSecurityPolicyNonceGeneratingWebFilter nonceGeneratingFilter; private HeaderSpec() { + this.writers = new ArrayList<>(Arrays.asList(this.cacheControl, this.contentTypeOptions, this.hsts, + this.frameOptions, this.xss, this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy, + this.referrerPolicy, this.crossOriginOpenerPolicy, this.crossOriginEmbedderPolicy, + this.crossOriginResourcePolicy)); } /** @@ -2543,7 +2546,7 @@ public HeaderSpec frameOptions(Customizer frameOptionsCustomiz */ public HeaderSpec writer(ServerHttpHeadersWriter serverHttpHeadersWriter) { Assert.notNull(serverHttpHeadersWriter, "serverHttpHeadersWriter cannot be null"); - this.customHeadersWriters.add(serverHttpHeadersWriter); + this.writers.add(serverHttpHeadersWriter); return this; } @@ -2559,13 +2562,7 @@ public HeaderSpec hsts(Customizer hstsCustomizer) { } protected void configure(ServerHttpSecurity http) { - Stream builtInWriters = Stream - .of(this.cacheControl, this.contentTypeOptions, this.hsts, this.frameOptions, this.xss, - this.featurePolicy, this.permissionsPolicy, this.contentSecurityPolicy, this.referrerPolicy, - this.crossOriginOpenerPolicy, this.crossOriginEmbedderPolicy, this.crossOriginResourcePolicy) - .filter(Objects::nonNull); - ServerHttpHeadersWriter writer = new CompositeServerHttpHeadersWriter( - Stream.concat(builtInWriters, this.customHeadersWriters.stream()).toList()); + ServerHttpHeadersWriter writer = new CompositeServerHttpHeadersWriter(this.writers); HttpHeaderWriterWebFilter result = new HttpHeaderWriterWebFilter(writer); http.addFilterAt(result, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); // nonceGeneratingFilter is instantiated iff CSP is configured @@ -2687,7 +2684,7 @@ private CacheSpec() { * @return the {@link HeaderSpec} to configure */ public HeaderSpec disable() { - HeaderSpec.this.cacheControl = null; + HeaderSpec.this.writers.remove(HeaderSpec.this.cacheControl); return HeaderSpec.this; } @@ -2708,7 +2705,7 @@ private ContentTypeOptionsSpec() { * @return the {@link HeaderSpec} to configure */ public HeaderSpec disable() { - HeaderSpec.this.contentTypeOptions = null; + HeaderSpec.this.writers.remove(HeaderSpec.this.contentTypeOptions); return HeaderSpec.this; } @@ -2740,7 +2737,7 @@ public HeaderSpec mode(XFrameOptionsServerHttpHeadersWriter.Mode mode) { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec disable() { - HeaderSpec.this.frameOptions = null; + HeaderSpec.this.writers.remove(HeaderSpec.this.frameOptions); return HeaderSpec.this; } @@ -2799,7 +2796,7 @@ public HstsSpec preload(boolean preload) { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec disable() { - HeaderSpec.this.hsts = null; + HeaderSpec.this.writers.remove(HeaderSpec.this.hsts); return HeaderSpec.this; } @@ -2820,7 +2817,7 @@ private XssProtectionSpec() { * @return the {@link HeaderSpec} to continue configuring */ public HeaderSpec disable() { - HeaderSpec.this.xss = null; + HeaderSpec.this.writers.remove(HeaderSpec.this.xss); return HeaderSpec.this; } From 2be35ce3de61089812005ef08347e4f406cdb7c7 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 4 Sep 2026 19:19:37 +0800 Subject: [PATCH 19/26] Avoid failing fast in CompositeServerHttpHeadersWriter Signed-off-by: Ziqin Wang --- .../header/CompositeServerHttpHeadersWriter.java | 3 ++- .../header/CompositeServerHttpHeadersWriterTests.java | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/web/src/main/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriter.java index 7b92a221c75..2d50952d992 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriter.java @@ -44,7 +44,8 @@ public CompositeServerHttpHeadersWriter(List writers) { @Override public Mono writeHttpHeaders(ServerWebExchange exchange) { - return Flux.fromIterable(this.writers).concatMap((w) -> w.writeHttpHeaders(exchange)).then(); + // Don't fail fast so that other protection headers could still be written + return Flux.fromIterable(this.writers).concatMapDelayError((w) -> w.writeHttpHeaders(exchange)).then(); } } diff --git a/web/src/test/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriterTests.java index 03f716ac07b..1affab64c2e 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/CompositeServerHttpHeadersWriterTests.java @@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.verify; /** @@ -86,6 +87,16 @@ public void writeHttpHeadersWhenNoErrorThenNoError() { verify(this.writer2).writeHttpHeaders(this.exchange); } + @Test + void writeHttpHeadersWhenSomeWriterThrowsThenOthersStillWrite() { + given(this.writer1.writeHttpHeaders(this.exchange)).willReturn(Mono.error(new RuntimeException())); + given(this.writer2.writeHttpHeaders(this.exchange)).willReturn(Mono.empty()); + Mono result = this.writer.writeHttpHeaders(this.exchange); + StepVerifier.create(result).expectError().verify(); + then(this.writer1).should().writeHttpHeaders(this.exchange); + then(this.writer2).should().writeHttpHeaders(this.exchange); + } + @Test public void writeHttpHeadersSequential() throws Exception { AtomicBoolean slowDone = new AtomicBoolean(); From cba8dd8dfea12f556cc6231f67ecc6bb71c51fb6 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Fri, 4 Sep 2026 19:19:50 +0800 Subject: [PATCH 20/26] Avoid failing fast in CompositeHeaderWriter Signed-off-by: Ziqin Wang --- .../header/writers/CompositeHeaderWriter.java | 17 ++++++++++++++++- .../writers/CompositeHeaderWriterTests.java | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/web/src/main/java/org/springframework/security/web/header/writers/CompositeHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/CompositeHeaderWriter.java index 09158a4f7f0..ea4c802474f 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/CompositeHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/CompositeHeaderWriter.java @@ -16,6 +16,7 @@ package org.springframework.security.web.header.writers; +import java.util.ArrayList; import java.util.List; import jakarta.servlet.http.HttpServletRequest; @@ -46,7 +47,21 @@ public CompositeHeaderWriter(List headerWriters) { @Override public void writeHeaders(HttpServletRequest request, HttpServletResponse response) { - this.headerWriters.forEach((headerWriter) -> headerWriter.writeHeaders(request, response)); + List exceptions = new ArrayList<>(); + this.headerWriters.forEach((headerWriter) -> { + // Don't fail fast so that other protection headers could still be written + try { + headerWriter.writeHeaders(request, response); + } + catch (RuntimeException ex) { + exceptions.add(ex); + } + }); + if (!exceptions.isEmpty()) { + RuntimeException ex = new RuntimeException("Exception(s) thrown when writing headers"); + exceptions.forEach(ex::addSuppressed); + throw ex; + } } } diff --git a/web/src/test/java/org/springframework/security/web/header/writers/CompositeHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/CompositeHeaderWriterTests.java index 4e73b42e2f3..294a94cee69 100644 --- a/web/src/test/java/org/springframework/security/web/header/writers/CompositeHeaderWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/writers/CompositeHeaderWriterTests.java @@ -18,14 +18,20 @@ import java.util.Arrays; import java.util.Collections; +import java.util.List; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.web.header.HeaderWriter; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatRuntimeException; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -49,6 +55,19 @@ public void writeHeadersWhenConfiguredWithDelegatesThenInvokesEach() { verify(two).writeHeaders(request, response); } + @Test + void writeHeadersWhenSomeWriterThrowsThenOthersStillWrite() { + HttpServletRequest request = new MockHttpServletRequest(); + HttpServletResponse response = new MockHttpServletResponse(); + HeaderWriter writer1 = mock(HeaderWriter.class); + HeaderWriter writer2 = mock(HeaderWriter.class); + CompositeHeaderWriter headerWriter = new CompositeHeaderWriter(List.of(writer1, writer2)); + willThrow(RuntimeException.class).given(writer1).writeHeaders(request, response); + assertThatRuntimeException().isThrownBy(() -> headerWriter.writeHeaders(request, response)); + then(writer1).should().writeHeaders(request, response); + then(writer2).should().writeHeaders(request, response); + } + @Test public void constructorWhenPassingEmptyListThenThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> new CompositeHeaderWriter(Collections.emptyList())); From 3dae3b0b627ae086dac1f9e0868e362c574c570f Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Sun, 6 Sep 2026 17:43:45 +0800 Subject: [PATCH 21/26] Undo changes on visibility of CSP header constants Signed-off-by: Ziqin Wang --- .../headers/ContentSecurityPolicyDslTests.kt | 35 +++++++------------ .../ContentSecurityPolicyHeaderWriter.java | 4 +-- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt index 1d3af904191..d8ceb5c6cad 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDslTests.kt @@ -31,12 +31,14 @@ import org.springframework.security.config.annotation.web.invoke import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter import org.springframework.security.web.util.matcher.AnyRequestMatcher import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get +private val CONTENT_SECURITY_POLICY = "Content-Security-Policy"; +private val CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only" + /** * Tests for [ContentSecurityPolicyDsl] * @@ -58,7 +60,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, "default-src 'self'") } + header { string(CONTENT_SECURITY_POLICY, "default-src 'self'") } } } @@ -84,10 +86,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, - "default-src 'self'; script-src trustedscripts.example.com") - } + header { string(CONTENT_SECURITY_POLICY, "default-src 'self'; script-src trustedscripts.example.com") } } } @@ -115,10 +114,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER, - "default-src 'self'") - } + header { string(CONTENT_SECURITY_POLICY_REPORT_ONLY, "default-src 'self'") } } } @@ -147,8 +143,7 @@ class ContentSecurityPolicyDslTests { secure = true }.andExpect { header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, - matchesPattern("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$")) + string(CONTENT_SECURITY_POLICY, matchesPattern("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$")) } } } @@ -178,8 +173,7 @@ class ContentSecurityPolicyDslTests { secure = true }.andExpect { header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, - matchesPattern("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$")) + string(CONTENT_SECURITY_POLICY, matchesPattern("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$")) } } } @@ -211,15 +205,14 @@ class ContentSecurityPolicyDslTests { accept = MediaType.TEXT_HTML }.andExpect { header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, - "default-src 'self'") + string(CONTENT_SECURITY_POLICY, "default-src 'self'") } } this.mockMvc.get("/") { secure = true accept = MediaType.TEXT_PLAIN }.andExpect { - header { doesNotExist(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER) } + header { doesNotExist(CONTENT_SECURITY_POLICY) } } } @@ -252,22 +245,20 @@ class ContentSecurityPolicyDslTests { secure = true }.andExpect { header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, - "default-src 'self'") + string(CONTENT_SECURITY_POLICY, "default-src 'self'") } } this.mockMvc.get("/bar/foo") { secure = true }.andExpect { header { - string(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER, - "default-src 'self'") + string(CONTENT_SECURITY_POLICY, "default-src 'self'") } } this.mockMvc.get("/foobar") { secure = true }.andExpect { - header { doesNotExist(ContentSecurityPolicyHeaderWriter.CONTENT_SECURITY_POLICY_HEADER) } + header { doesNotExist(CONTENT_SECURITY_POLICY) } } } diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index b07db14fd8b..71c73b2a2bf 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -116,9 +116,9 @@ */ public final class ContentSecurityPolicyHeaderWriter implements HeaderWriter { - public static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; + private static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy"; - public static final String CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only"; + private static final String CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only"; private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'"; From aeeafb9518f7699725dcec73911ae688f59838c5 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Mon, 7 Sep 2026 05:29:27 +0800 Subject: [PATCH 22/26] Introduce a getter for CSP nonce Signed-off-by: Ziqin Wang --- .../configurers/HeadersConfigurerTests.java | 10 ++-- .../config/web/server/HeaderSpecTests.java | 9 ++-- .../header/ContentSecurityPolicyNonce.java | 31 ++++++++++++ ...ntSecurityPolicyNonceGeneratingFilter.java | 25 ++++++++-- .../ContentSecurityPolicyHeaderWriter.java | 24 +++++---- ...ecurityPolicyNonceGeneratingWebFilter.java | 28 +++++++++-- ...SecurityPolicyServerHttpHeadersWriter.java | 27 +++++----- ...urityPolicyNonceGeneratingFilterTests.java | 34 ++++++------- ...ontentSecurityPolicyHeaderWriterTests.java | 10 ++-- ...tyPolicyNonceGeneratingWebFilterTests.java | 49 +++++++++++++------ ...ityPolicyServerHttpHeadersWriterTests.java | 6 ++- 11 files changed, 167 insertions(+), 86 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonce.java diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java index 6950ba1f3a6..37aaef3b9f2 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurerTests.java @@ -19,7 +19,6 @@ import java.net.URI; import java.util.LinkedHashMap; import java.util.Map; -import java.util.function.Supplier; import java.util.regex.Pattern; import com.google.common.net.HttpHeaders; @@ -37,6 +36,7 @@ import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.security.web.header.writers.CrossOriginEmbedderPolicyHeaderWriter; import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter; import org.springframework.security.web.header.writers.CrossOriginResourcePolicyHeaderWriter; @@ -1173,26 +1173,26 @@ static class TestCspNonceController { @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - String defaultAttribute(@RequestAttribute("_csp_nonce") Supplier cspNonce) { + String defaultAttribute(@RequestAttribute("_csp") ContentSecurityPolicyNonce cspNonce) { return """ Default - """.formatted(cspNonce.get()); + """.formatted(cspNonce.getNonce()); } @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - String custom(@RequestAttribute("CUSTOM_NONCE") Supplier cspNonce) { + String custom(@RequestAttribute("CUSTOM_NONCE") ContentSecurityPolicyNonce cspNonce) { return """ Custom - """.formatted(cspNonce.get()); + """.formatted(cspNonce.getNonce()); } } diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index 81befac26ad..d14c1ebf5ed 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java @@ -29,6 +29,7 @@ import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter; import org.springframework.security.web.server.header.ContentTypeOptionsServerHttpHeadersWriter; import org.springframework.security.web.server.header.CrossOriginEmbedderPolicyServerHttpHeadersWriter; @@ -665,8 +666,8 @@ static class ReactiveTestCspNonceController { @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - Mono defaultAttribute(@RequestAttribute("_csp_nonce") Mono cspNonce) { - return cspNonce.map(""" + Mono defaultAttribute(@RequestAttribute("_csp") Mono cspNonce) { + return cspNonce.map(ContentSecurityPolicyNonce::getNonce).map(""" @@ -677,8 +678,8 @@ Mono defaultAttribute(@RequestAttribute("_csp_nonce") Mono cspNo @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - Mono custom(@RequestAttribute("CUSTOM_NONCE") Mono cspNonce) { - return cspNonce.map(""" + Mono custom(@RequestAttribute("CUSTOM_NONCE") Mono cspNonce) { + return cspNonce.map(ContentSecurityPolicyNonce::getNonce).map(""" diff --git a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonce.java b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonce.java new file mode 100644 index 00000000000..56cfef8d60c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonce.java @@ -0,0 +1,31 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.header; + +/** + * A container of a Content Security Policy nonce for universal access. + * + * @author Ziqin Wang + * @since 7.2 + * @see ContentSecurityPolicyNonceGeneratingFilter + * @see org.springframework.security.web.server.header.ContentSecurityPolicyNonceGeneratingWebFilter + */ +public interface ContentSecurityPolicyNonce { + + String getNonce(); + +} diff --git a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java index e1f9044cfdf..a5b61d8bb29 100644 --- a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -32,8 +32,8 @@ import org.springframework.web.filter.OncePerRequestFilter; /** - * A filter which generates a nonce string for Content Security Policy and sets it as a - * request attribute. + * A filter which generates a {@link ContentSecurityPolicyNonce nonce} for Content + * Security Policy and sets it as a request attribute. * *

* {@link org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter} @@ -46,7 +46,7 @@ */ public final class ContentSecurityPolicyNonceGeneratingFilter extends OncePerRequestFilter { - private String attributeName = "_csp_nonce"; + private String attributeName = "_csp"; private final StringKeyGenerator nonceGenerator; @@ -74,10 +74,10 @@ public ContentSecurityPolicyNonceGeneratingFilter() { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - Supplier deferredNonce = SingletonSupplier.of(this.nonceGenerator::generateKey); + ContentSecurityPolicyNonce deferredNonce = new DeferredContentSecurityPolicyNonce(this.nonceGenerator); // For internal use - request.setAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName(), deferredNonce); + request.setAttribute(ContentSecurityPolicyNonce.class.getName(), deferredNonce); // Exposed to users request.setAttribute(this.attributeName, deferredNonce); @@ -96,4 +96,19 @@ public void setAttributeName(String attributeName) { this.attributeName = attributeName; } + private static final class DeferredContentSecurityPolicyNonce implements ContentSecurityPolicyNonce { + + private final Supplier delegate; + + private DeferredContentSecurityPolicyNonce(StringKeyGenerator nonceGenerator) { + this.delegate = SingletonSupplier.of(nonceGenerator::generateKey); + } + + @Override + public String getNonce() { + return this.delegate.get(); + } + + } + } diff --git a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java index 71c73b2a2bf..e3eee9f23d2 100644 --- a/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java +++ b/web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java @@ -16,12 +16,10 @@ package org.springframework.security.web.header.writers; -import java.util.function.Supplier; - import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.security.web.header.HeaderWriter; import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -70,18 +68,18 @@ *

* To ease writing nonce-based CSP headers, this class replaces the {@code {nonce}} * placeholder in the {@code policyDirectives} with a real nonce value read from a servlet - * request attribute named {@code _csp_nonce} (or another configured attribute name). A + * request attribute. A * {@link org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter} - * can be configured to generate a unique secure random {@code _csp_nonce} attribute for - * each request. + * can be configured to generate a unique secure random {@link ContentSecurityPolicyNonce} + * attribute for each request. *

* *

* For example, if the configured {@code policyDirectives} is {@code script-src 'self' * 'nonce-{nonce}'}, and a * {@link org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter} - * has set the {@code _csp_nonce} attribute to {@code "Nc3n83cnSAd3wc3Sasdfn9"}, then the - * written HTTP header value would be + * has set a {@link ContentSecurityPolicyNonce} of {@code "Nc3n83cnSAd3wc3Sasdfn9"}, then + * the written HTTP header value would be * {@code script-src 'self' 'nonce-Nc3n83cnSAd3wc3Sasdfn9'}. *

* @@ -165,12 +163,12 @@ public void writeHeaders(HttpServletRequest request, HttpServletResponse respons if (!response.containsHeader(headerName)) { String csp; if (this.isNonceBased) { - Supplier deferredNonce = (Supplier) request - .getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName()); - Assert.state(deferredNonce != null, + ContentSecurityPolicyNonce nonce = (ContentSecurityPolicyNonce) request + .getAttribute(ContentSecurityPolicyNonce.class.getName()); + Assert.state(nonce != null, () -> "Failed to replace {nonce} placeholders since no nonce found as a request attribute " - + ContentSecurityPolicyNonceGeneratingFilter.class.getName()); - csp = this.policyDirectives.replace(NONCE_PLACEHOLDER, deferredNonce.get()); + + ContentSecurityPolicyNonce.class.getName()); + csp = this.policyDirectives.replace(NONCE_PLACEHOLDER, nonce.getNonce()); } else { csp = this.policyDirectives; diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java index c7d5c759e01..a228e133439 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilter.java @@ -23,14 +23,15 @@ import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; /** - * A filter which generates a nonce string for Content Security Policy and sets it as an - * exchange attribute. + * A filter which generates a {@link ContentSecurityPolicyNonce nonce} for Content + * Security Policy and sets it as an exchange attribute. * *

* {@link org.springframework.security.web.server.header.ContentSecurityPolicyServerHttpHeadersWriter} @@ -43,7 +44,7 @@ */ public final class ContentSecurityPolicyNonceGeneratingWebFilter implements WebFilter { - private String attributeName = "_csp_nonce"; + private String attributeName = "_csp"; private final StringKeyGenerator nonceGenerator; @@ -69,11 +70,13 @@ public ContentSecurityPolicyNonceGeneratingWebFilter() { @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - Mono deferredNonce = Mono.fromSupplier(this.nonceGenerator::generateKey).cache(); + Mono deferredNonce = Mono + .fromSupplier(() -> new SimpleContentSecurityPolicyNonce(this.nonceGenerator.generateKey())) + .cache(); Map attributes = exchange.getAttributes(); // For internal use - attributes.put(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName(), deferredNonce); + attributes.put(ContentSecurityPolicyNonce.class.getName(), deferredNonce); // Exposed to users attributes.put(this.attributeName, deferredNonce); @@ -92,4 +95,19 @@ public void setAttributeName(String attributeName) { this.attributeName = attributeName; } + private static final class SimpleContentSecurityPolicyNonce implements ContentSecurityPolicyNonce { + + private final String nonce; + + private SimpleContentSecurityPolicyNonce(String nonce) { + this.nonce = nonce; + } + + @Override + public String getNonce() { + return this.nonce; + } + + } + } diff --git a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java index 71afed6ec4e..ae6affb96c6 100644 --- a/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java +++ b/web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java @@ -22,6 +22,7 @@ import reactor.core.publisher.Mono; import org.springframework.http.HttpHeaders; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import org.springframework.util.Assert; @@ -41,16 +42,17 @@ *

* To ease writing nonce-based CSP headers, this class replaces the {@code {nonce}} * placeholder in the {@code policyDirectives} with a real nonce value read from a - * {@link ServerWebExchange#getAttribute(String) request attribute} named - * {@code _csp_nonce} (or another configured attribute name). A + * {@link ServerWebExchange#getAttribute(String) request attribute}. A * {@link ContentSecurityPolicyNonceGeneratingWebFilter} can be configured to generate a - * unique secure random {@code _csp_nonce} attribute for each request. + * unique secure random {@code Mono} attribute for each + * request. * *

* For example, if the configured {@code policyDirectives} is {@code script-src 'self' - * 'nonce-{nonce}'}, and a {@link ContentSecurityPolicyNonceGeneratingWebFilter} has set - * the {@code _csp_nonce} attribute to {@code "Nc3n83cnSAd3wc3Sasdfn9"}, then the written - * HTTP header value would be {@code script-src 'self' 'nonce-Nc3n83cnSAd3wc3Sasdfn9'}. + * 'nonce-{nonce}'}, and a {@link ContentSecurityPolicyNonceGeneratingWebFilter} has set a + * {@link ContentSecurityPolicyNonce} of {@code "Nc3n83cnSAd3wc3Sasdfn9"}, then the + * written HTTP header value would be + * {@code script-src 'self' 'nonce-Nc3n83cnSAd3wc3Sasdfn9'}. * * @author Vedran Pavic * @author Ziqin Wang @@ -91,16 +93,17 @@ public Mono writeHttpHeaders(ServerWebExchange exchange) { return Mono.empty(); } - Mono deferredNonce = exchange - .getAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); + Mono deferredNonce = exchange + .getAttribute(ContentSecurityPolicyNonce.class.getName()); if (deferredNonce == null) { return Mono.error(new IllegalStateException( "Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " - + ContentSecurityPolicyNonceGeneratingWebFilter.class.getName())); + + ContentSecurityPolicyNonce.class.getName())); } - return deferredNonce - .doOnNext((nonce) -> headers.put(headerName, List.of(csp.replace(NONCE_PLACEHOLDER, nonce)))) - .then(); + return deferredNonce.doOnNext((ContentSecurityPolicyNonce nonce) -> { + String cspWithNonce = csp.replace(NONCE_PLACEHOLDER, nonce.getNonce()); + headers.put(headerName, List.of(cspWithNonce)); + }).then(); }); } diff --git a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java index de6cab2827c..4f65c6850e8 100644 --- a/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilterTests.java @@ -16,8 +16,6 @@ package org.springframework.security.web.header; -import java.util.function.Supplier; - import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; @@ -45,7 +43,7 @@ */ class ContentSecurityPolicyNonceGeneratingFilterTests { - private static final String DEFAULT_ATTRIBUTE_NAME = "_csp_nonce"; + private static final String DEFAULT_ATTRIBUTE_NAME = "_csp"; private static final int MIN_STRENGTH_IN_BYTE = 16; @@ -59,13 +57,13 @@ void attributeShouldBeAddedAndNonceIsLongEnoughBase64ByDefault() throws Exceptio filter.doFilter(request, response, chain); int minExpectedLength = (int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE); - assertThat(request.getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName())) - .asInstanceOf(type(Supplier.class)) - .extracting(Supplier::get, as(STRING)) + assertThat(request.getAttribute(ContentSecurityPolicyNonce.class.getName())) + .asInstanceOf(type(ContentSecurityPolicyNonce.class)) + .extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) .isBase64() .hasSizeGreaterThanOrEqualTo(minExpectedLength); - assertThat(request.getAttribute(DEFAULT_ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) - .extracting(Supplier::get, as(STRING)) + assertThat(request.getAttribute(DEFAULT_ATTRIBUTE_NAME)).asInstanceOf(type(ContentSecurityPolicyNonce.class)) + .extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) .isBase64() .hasSizeGreaterThanOrEqualTo(minExpectedLength); then(chain).should().doFilter(request, response); @@ -82,12 +80,12 @@ void customAttributeNameIsUsed() throws Exception { filter.setAttributeName(customAttributeName); filter.doFilter(request, response, chain); - assertThat(request.getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName())) - .asInstanceOf(type(Supplier.class)) - .extracting(Supplier::get, as(STRING)) + assertThat(request.getAttribute(ContentSecurityPolicyNonce.class.getName())) + .asInstanceOf(type(ContentSecurityPolicyNonce.class)) + .extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) .isBase64(); - assertThat(request.getAttribute(customAttributeName)).asInstanceOf(type(Supplier.class)) - .extracting(Supplier::get, as(STRING)) + assertThat(request.getAttribute(customAttributeName)).asInstanceOf(type(ContentSecurityPolicyNonce.class)) + .extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) .isBase64(); then(chain).should().doFilter(request, response); } @@ -104,12 +102,12 @@ void customNonceGeneratorIsUsed() throws Exception { Filter filter = new ContentSecurityPolicyNonceGeneratingFilter(nonceGenerator); filter.doFilter(request, response, chain); - assertThat(request.getAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName())) - .asInstanceOf(type(Supplier.class)) - .extracting(Supplier::get, as(STRING)) + assertThat(request.getAttribute(ContentSecurityPolicyNonce.class.getName())) + .asInstanceOf(type(ContentSecurityPolicyNonce.class)) + .extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) .isSameAs(nonce); - assertThat(request.getAttribute(DEFAULT_ATTRIBUTE_NAME)).asInstanceOf(type(Supplier.class)) - .extracting(Supplier::get, as(STRING)) + assertThat(request.getAttribute(DEFAULT_ATTRIBUTE_NAME)).asInstanceOf(type(ContentSecurityPolicyNonce.class)) + .extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) .isSameAs(nonce); then(nonceGenerator).should().generateKey(); } diff --git a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java index 7def86bee67..0f549a3728d 100644 --- a/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriterTests.java @@ -16,14 +16,12 @@ package org.springframework.security.web.header.writers; -import java.util.function.Supplier; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -134,8 +132,8 @@ public void writeContentSecurityPolicyReportOnlyHeaderWhenNotPresent() { @Test public void writeNonceBasedCspWhenNoncePresent() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); - this.request.setAttribute(ContentSecurityPolicyNonceGeneratingFilter.class.getName(), - (Supplier) () -> "Test+Nonce+Value"); + this.request.setAttribute(ContentSecurityPolicyNonce.class.getName(), + (ContentSecurityPolicyNonce) () -> "Test+Nonce+Value"); this.writer.writeHeaders(this.request, this.response); assertThat(this.response.getHeader(CONTENT_SECURITY_POLICY_HEADER)) .isEqualTo("script-src 'nonce-Test+Nonce+Value'; style-src 'nonce-Test+Nonce+Value'"); @@ -146,7 +144,7 @@ public void writeNonceBasedCspWhenNonceUnsetThenThrows() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'"); assertThatIllegalStateException().isThrownBy(() -> this.writer.writeHeaders(this.request, this.response)) .withMessage("Failed to replace {nonce} placeholders since no nonce found as a request attribute " - + ContentSecurityPolicyNonceGeneratingFilter.class.getName()); + + ContentSecurityPolicyNonce.class.getName()); } } diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java index d94085e9664..39ee0949184 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyNonceGeneratingWebFilterTests.java @@ -24,12 +24,15 @@ import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.security.crypto.keygen.KeyGenerators; import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; +import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -39,7 +42,7 @@ */ class ContentSecurityPolicyNonceGeneratingWebFilterTests { - private static final String DEFAULT_ATTRIBUTE_NAME = "_csp_nonce"; + private static final String DEFAULT_ATTRIBUTE_NAME = "_csp"; private static final int MIN_STRENGTH_IN_BYTE = 16; @@ -51,16 +54,20 @@ void attributeShouldBeAddedAndNonceIsLongEnoughBase64ByDefault() { WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); - Mono internalDeferredNonce = exchange - .getRequiredAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); - Mono deferredNonce = exchange.getRequiredAttribute(DEFAULT_ATTRIBUTE_NAME); + Mono internalDeferredNonce = exchange + .getRequiredAttribute(ContentSecurityPolicyNonce.class.getName()); + Mono deferredNonce = exchange.getRequiredAttribute(DEFAULT_ATTRIBUTE_NAME); int minExpectedLength = (int) Math.ceil(4.0 / 3 * MIN_STRENGTH_IN_BYTE); StepVerifier.create(internalDeferredNonce) - .assertNext((nonce) -> assertThat(nonce).isBase64().hasSizeGreaterThanOrEqualTo(minExpectedLength)) + .assertNext((nonce) -> assertThat(nonce).extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) + .isBase64() + .hasSizeGreaterThanOrEqualTo(minExpectedLength)) .verifyComplete(); StepVerifier.create(deferredNonce) - .assertNext((nonce) -> assertThat(nonce).isBase64().hasSizeGreaterThanOrEqualTo(minExpectedLength)) + .assertNext((nonce) -> assertThat(nonce).extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) + .isBase64() + .hasSizeGreaterThanOrEqualTo(minExpectedLength)) .verifyComplete(); then(chain).should().filter(exchange); } @@ -75,12 +82,18 @@ void customAttributeNameIsUsed() { var filter = new ContentSecurityPolicyNonceGeneratingWebFilter(); filter.setAttributeName(customAttributeName); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); - Mono internalDeferredNonce = exchange - .getRequiredAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); - Mono deferredNonce = exchange.getRequiredAttribute(customAttributeName); + Mono internalDeferredNonce = exchange + .getRequiredAttribute(ContentSecurityPolicyNonce.class.getName()); + Mono deferredNonce = exchange.getRequiredAttribute(customAttributeName); - StepVerifier.create(internalDeferredNonce).assertNext((nonce) -> assertThat(nonce).isBase64()).verifyComplete(); - StepVerifier.create(deferredNonce).assertNext((nonce) -> assertThat(nonce).isBase64()).verifyComplete(); + StepVerifier.create(internalDeferredNonce) + .assertNext((nonce) -> assertThat(nonce).extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) + .isBase64()) + .verifyComplete(); + StepVerifier.create(deferredNonce) + .assertNext((nonce) -> assertThat(nonce).extracting(ContentSecurityPolicyNonce::getNonce, as(STRING)) + .isBase64()) + .verifyComplete(); then(chain).should().filter(exchange); } @@ -95,12 +108,16 @@ void customNonceGeneratorIsUsed() { WebFilter filter = new ContentSecurityPolicyNonceGeneratingWebFilter(nonceGenerator); StepVerifier.create(filter.filter(exchange, chain)).verifyComplete(); - Mono internalDeferredNonce = exchange - .getRequiredAttribute(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName()); - Mono deferredNonce = exchange.getRequiredAttribute(DEFAULT_ATTRIBUTE_NAME); + Mono internalDeferredNonce = exchange + .getRequiredAttribute(ContentSecurityPolicyNonce.class.getName()); + Mono deferredNonce = exchange.getRequiredAttribute(DEFAULT_ATTRIBUTE_NAME); - StepVerifier.create(internalDeferredNonce).expectNext(nonce).verifyComplete(); - StepVerifier.create(deferredNonce).expectNext(nonce).verifyComplete(); + StepVerifier.create(internalDeferredNonce) + .expectNextMatches((cspNonce) -> cspNonce.getNonce().equals(nonce)) + .verifyComplete(); + StepVerifier.create(deferredNonce) + .expectNextMatches((cspNonce) -> cspNonce.getNonce().equals(nonce)) + .verifyComplete(); then(nonceGenerator).should().generateKey(); } diff --git a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java index 43bcd3b3aef..11be748cdb1 100644 --- a/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriterTests.java @@ -24,6 +24,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.web.header.ContentSecurityPolicyNonce; import org.springframework.web.server.ServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; @@ -100,7 +101,8 @@ public void writeHeadersWhenAlreadyWrittenThenDoesNotOverride() { public void writeNonceBasedCspWhenNoncePresent() { this.writer.setPolicyDirectives("script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'"); this.exchange.getAttributes() - .put(ContentSecurityPolicyNonceGeneratingWebFilter.class.getName(), Mono.just("Test+Nonce+Value")); + .put(ContentSecurityPolicyNonce.class.getName(), + Mono.just((ContentSecurityPolicyNonce) () -> "Test+Nonce+Value")); StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)).verifyComplete(); HttpHeaders headers = this.exchange.getResponse().getHeaders(); assertThat(headers.get(CONTENT_SECURITY_POLICY_HEADER)) @@ -113,7 +115,7 @@ public void writeNonceBasedCspWhenNonceUnsetThenEmitError() { StepVerifier.create(this.writer.writeHttpHeaders(this.exchange)) .expectErrorSatisfies((ex) -> assertThat(ex).isInstanceOf(IllegalStateException.class) .hasMessage("Failed to replace {nonce} placeholders since no nonce found as an exchange attribute " - + ContentSecurityPolicyNonceGeneratingWebFilter.class.getName())) + + ContentSecurityPolicyNonce.class.getName())) .verify(); } From 9ebbf70cc5ecfff28d0f391ccfd3d15e1bffd609 Mon Sep 17 00:00:00 2001 From: Ziqin Wang Date: Mon, 7 Sep 2026 05:54:01 +0800 Subject: [PATCH 23/26] Update Javadoc @since version for nonce-based CSP to 7.2 Signed-off-by: Ziqin Wang --- .../annotation/web/configurers/HeadersConfigurer.java | 6 +++--- .../security/config/web/server/ServerHttpSecurity.java | 6 +++--- .../annotation/web/headers/ContentSecurityPolicyDsl.kt | 6 +++--- .../config/web/server/ServerContentSecurityPolicyDsl.kt | 6 +++--- .../header/ContentSecurityPolicyNonceGeneratingFilter.java | 2 +- .../ContentSecurityPolicyNonceGeneratingWebFilter.java | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java index 90b692cfbb4..0ab7333c780 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java @@ -985,7 +985,7 @@ public ContentSecurityPolicyConfig reportOnly() { * @return the {@link ContentSecurityPolicyConfig} for additional configuration * @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null} * or empty - * @since 7.1 + * @since 7.2 */ public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) { Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); @@ -1002,7 +1002,7 @@ public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) * @throws IllegalArgumentException if {@code requestMatcher} is null * @throws IllegalStateException if a {@link RequestMatcher} is already configured * by a previous call of this method or {@link #requestMatchers(String...)} - * @since 7.1 + * @since 7.2 * @see #requestMatchers(String...) */ public ContentSecurityPolicyConfig requestMatcher(RequestMatcher requestMatcher) { @@ -1024,7 +1024,7 @@ public ContentSecurityPolicyConfig requestMatcher(RequestMatcher requestMatcher) * {@link PathPatternRequestMatcher.Builder#matcher(String)} * @throws IllegalStateException if a {@link RequestMatcher} is already configured * by a previous call of this method or {@link #requestMatcher(RequestMatcher)} - * @since 7.1 + * @since 7.2 * @see #requestMatcher(RequestMatcher) */ public ContentSecurityPolicyConfig requestMatchers(String... pathPatterns) { diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 9d4176892a5..bdebcec7a30 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -2885,7 +2885,7 @@ public HeaderSpec policyDirectives(String policyDirectives) { * @return the {@link ContentSecurityPolicySpec} to continue configuring * @throws IllegalArgumentException if {@code nonceAttributeName} is * {@code null} or empty - * @since 7.1 + * @since 7.2 */ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); @@ -2903,7 +2903,7 @@ public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { * @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is * already configured by a previous call of this method or * {@link #exchangeMatchers(String...)} - * @since 7.1 + * @since 7.2 * @see #exchangeMatchers(String...) */ public ContentSecurityPolicySpec exchangeMatcher(ServerWebExchangeMatcher matcher) { @@ -2926,7 +2926,7 @@ public ContentSecurityPolicySpec exchangeMatcher(ServerWebExchangeMatcher matche * @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is * already configured by a previous call of this method or * {@link #exchangeMatcher(ServerWebExchangeMatcher)} - * @since 7.1 + * @since 7.2 * @see #exchangeMatcher(ServerWebExchangeMatcher) */ public ContentSecurityPolicySpec exchangeMatchers(String... pathPatterns) { diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt index 30f71fc2b38..a6f9e3c525a 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt @@ -43,7 +43,7 @@ class ContentSecurityPolicyDsl { /** * The name of the servlet request attribute for the generated nonce. Views can read * this attribute to render the nonce in HTML. - * @since 7.1 + * @since 7.2 */ var nonceAttributeName: String? = null @@ -53,7 +53,7 @@ class ContentSecurityPolicyDsl { * [org.springframework.security.config.annotation.web.HeadersDsl.contentSecurityPolicy] * is configured. * You can configure either this property or [requestMatchers], but not both. - * @since 7.1 + * @since 7.2 * @see requestMatchers */ var requestMatcher: RequestMatcher? = null @@ -68,7 +68,7 @@ class ContentSecurityPolicyDsl { * You can configure either this method or [requestMatcher], but not both. * @param pathPatterns the path patterns to be matched with a * [org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher] - * @since 7.1 + * @since 7.2 * @see requestMatcher */ fun requestMatchers(vararg pathPatterns: String) { diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt index e017d5edb75..338c592740d 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerContentSecurityPolicyDsl.kt @@ -41,7 +41,7 @@ class ServerContentSecurityPolicyDsl { /** * The name of the request attribute for the generated nonce. Views can read this * attribute to render the nonce in HTML. - * @since 7.1 + * @since 7.2 */ var nonceAttributeName: String? = null @@ -50,7 +50,7 @@ class ServerContentSecurityPolicyDsl { * The default is to enable CSP in every response if [ServerHeadersDsl.contentSecurityPolicy] * is configured. * You can configure either this property or [exchangeMatchers], but not both. - * @since 7.1 + * @since 7.2 * @see exchangeMatchers */ var exchangeMatcher: ServerWebExchangeMatcher? = null @@ -64,7 +64,7 @@ class ServerContentSecurityPolicyDsl { * You can configure either this method or [exchangeMatcher], but not both. * @param pathPatterns the path patterns to be matched with a * [org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher] - * @since 7.1 + * @since 7.2 * @see exchangeMatcher */ fun exchangeMatchers(vararg pathPatterns: String) { diff --git a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java index a5b61d8bb29..2925772f4a9 100644 --- a/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -42,7 +42,7 @@ * {@code