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 dd9338eb655..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 @@ -18,16 +18,19 @@ import java.net.URI; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.configuration.ObjectPostProcessorConfiguration; 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.writers.CacheControlHeadersWriter; @@ -45,6 +48,8 @@ import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter; import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter; import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter.XFrameOptionsMode; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; @@ -75,6 +80,7 @@ * @author Vedran Pavic * @author Ankur Pathak * @author Daniel Garnier-Moiroux + * @author Ziqin Wang * @since 3.2 */ public class HeadersConfigurer> @@ -246,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; } @@ -273,6 +280,10 @@ public HeadersConfigurer defaultsDisabled() { public void configure(H http) { HeaderWriterFilter headersFilter = createHeaderWriterFilter(); http.addFilter(headersFilter); + // nonceGeneratingFilter is instantiated iff CSP is configured + if (this.contentSecurityPolicy.nonceGeneratingFilter != null) { + http.addFilterBefore(this.contentSecurityPolicy.nonceGeneratingFilter, HeaderWriterFilter.class); + } } /** @@ -937,11 +948,17 @@ public final class ContentSecurityPolicyConfig { private ContentSecurityPolicyHeaderWriter writer; + private ContentSecurityPolicyNonceGeneratingFilter nonceGeneratingFilter; + + private @Nullable RequestMatcher requestMatcher; + private ContentSecurityPolicyConfig() { } /** - * Sets the security policy directive(s) to be used in the response header. + * 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 ContentSecurityPolicyConfig} for additional configuration * @throws IllegalArgumentException if policyDirectives is null or empty @@ -961,6 +978,61 @@ public ContentSecurityPolicyConfig reportOnly() { return this; } + /** + * Sets the name of the servlet request attribute for the generated nonce. Views + * can read this attribute to render the nonce in HTML. + * @param nonceAttributeName the name of the nonce attribute + * @return the {@link ContentSecurityPolicyConfig} for additional configuration + * @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null} + * or empty + * @since 7.2 + */ + public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) { + Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); + this.nonceGeneratingFilter.setAttributeName(nonceAttributeName); + return this; + } + + /** + * Specifies the {@link RequestMatcher} to use for determining when CSP should be + * applied. The default is to enable CSP in every response if + * {@link HeadersConfigurer#contentSecurityPolicy(Customizer)} is configured. + * @param requestMatcher the {@link RequestMatcher} to use + * @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 #requestMatchers(String...)} + * @since 7.2 + * @see #requestMatchers(String...) + */ + 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; + } + + /** + * Specifies the matching path patterns for determining when CSP should be + * applied. The default is to enable CSP in every response if + * {@link HeadersConfigurer#contentSecurityPolicy(Customizer)} is configured. + * @param pathPatterns the path patterns to be matched with a + * {@link PathPatternRequestMatcher} + * @return the {@link ContentSecurityPolicyConfig} for additional configuration + * @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 #requestMatcher(RequestMatcher)} + * @since 7.2 + * @see #requestMatcher(RequestMatcher) + */ + public ContentSecurityPolicyConfig requestMatchers(String... pathPatterns) { + PathPatternRequestMatcher.Builder builder = HeadersConfigurer.this.getRequestMatcherBuilder(); + OrRequestMatcher matcher = new OrRequestMatcher(Arrays.stream(pathPatterns).map(builder::matcher).toList()); + return this.requestMatcher(matcher); + } + } public final class ReferrerPolicyConfig { diff --git a/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java index bffc868e13a..2422137d165 100644 --- a/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/HeadersBeanDefinitionParser.java @@ -33,6 +33,7 @@ import org.springframework.beans.factory.support.ManagedList; import org.springframework.beans.factory.xml.BeanDefinitionParser; import org.springframework.beans.factory.xml.ParserContext; +import org.springframework.security.web.header.ContentSecurityPolicyNonceGeneratingFilter; import org.springframework.security.web.header.HeaderWriterFilter; import org.springframework.security.web.header.writers.CacheControlHeadersWriter; import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter; @@ -54,6 +55,7 @@ import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter; import org.springframework.util.StringUtils; import org.springframework.util.xml.DomUtils; +import org.springframework.web.filter.CompositeFilter; /** * Parser for the {@code HeadersFilter}. @@ -133,6 +135,8 @@ public class HeadersBeanDefinitionParser implements BeanDefinitionParser { private ManagedList headerWriters; + private BeanDefinition nonceGeneratingFilter; + @Override public BeanDefinition parse(Element element, ParserContext parserContext) { this.headerWriters = new ManagedList<>(); @@ -164,7 +168,13 @@ else if (noWriters) { return null; } builder.addConstructorArgValue(this.headerWriters); - return builder.getBeanDefinition(); + BeanDefinition headerWriterFilter = builder.getBeanDefinition(); + if (this.nonceGeneratingFilter == null) { + return headerWriterFilter; + } + return BeanDefinitionBuilder.rootBeanDefinition(CompositeFilter.class) + .addPropertyValue("filters", ManagedList.of(this.nonceGeneratingFilter, headerWriterFilter)) + .getBeanDefinition(); } /** @@ -323,6 +333,9 @@ private void addContentSecurityPolicy(Element contentSecurityPolicyElement, Pars headersWriter.addPropertyValue("reportOnly", reportOnly); } this.headerWriters.add(headersWriter.getBeanDefinition()); + this.nonceGeneratingFilter = BeanDefinitionBuilder + .rootBeanDefinition(ContentSecurityPolicyNonceGeneratingFilter.class) + .getBeanDefinition(); } private void parseReferrerPolicyElement(Element element, ParserContext context) { 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..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 @@ -37,6 +37,7 @@ 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; @@ -183,6 +184,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; @@ -2452,6 +2454,7 @@ protected void configure(ServerHttpSecurity http) { * Configures HTTP Response Headers. * * @author Rob Winch + * @author Ziqin Wang * @since 5.0 * @see #headers(Customizer) */ @@ -2483,6 +2486,8 @@ public final class HeaderSpec { private CrossOriginResourcePolicyServerHttpHeadersWriter crossOriginResourcePolicy = new CrossOriginResourcePolicyServerHttpHeadersWriter(); + 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, @@ -2560,6 +2565,10 @@ protected void configure(ServerHttpSecurity http) { 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 + if (this.nonceGeneratingFilter != null) { + http.addFilterBefore(this.nonceGeneratingFilter, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); + } } /** @@ -2836,8 +2845,11 @@ public final class ContentSecurityPolicySpec { private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'"; + private @Nullable ServerWebExchangeMatcher exchangeMatcher; + private ContentSecurityPolicySpec() { HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(DEFAULT_SRC_SELF_POLICY); + HeaderSpec.this.nonceGeneratingFilter = new ContentSecurityPolicyNonceGeneratingWebFilter(); } /** @@ -2854,6 +2866,9 @@ public HeaderSpec reportOnly(boolean reportOnly) { /** * 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 HeaderSpec} to continue configuring */ @@ -2862,8 +2877,60 @@ public HeaderSpec policyDirectives(String policyDirectives) { return HeaderSpec.this; } - private ContentSecurityPolicySpec(String policyDirectives) { - HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); + /** + * 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 + * @return the {@link ContentSecurityPolicySpec} to continue configuring + * @throws IllegalArgumentException if {@code nonceAttributeName} is + * {@code null} or empty + * @since 7.2 + */ + public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { + Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); + HeaderSpec.this.nonceGeneratingFilter.setAttributeName(nonceAttributeName); + return this; + } + + /** + * Specifies the {@link ServerWebExchangeMatcher} to use for determining when + * CSP should be applied. The default is to enable CSP in every response if + * {@link HeaderSpec#contentSecurityPolicy(Customizer)} is configured. + * @param matcher the {@link ServerWebExchangeMatcher} to use + * @return the {@link ContentSecurityPolicySpec} to continue configuring + * @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 #exchangeMatchers(String...)} + * @since 7.2 + * @see #exchangeMatchers(String...) + */ + 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; + } + + /** + * Specifies the matching path patterns for determining when CSP should be + * applied. The default is to enable CSP in every response if + * {@link HeaderSpec#contentSecurityPolicy(Customizer)} is configured. + * @param pathPatterns the path patterns to be matched with a + * {@link PathPatternParserServerWebExchangeMatcher} + * @return the {@link ContentSecurityPolicySpec} to continue configuring + * @throws IllegalArgumentException if any path pattern is rejected by + * {@link PathPatternParserServerWebExchangeMatcher} + * @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is + * already configured by a previous call of this method or + * {@link #exchangeMatcher(ServerWebExchangeMatcher)} + * @since 7.2 + * @see #exchangeMatcher(ServerWebExchangeMatcher) + */ + public ContentSecurityPolicySpec exchangeMatchers(String... pathPatterns) { + return this.exchangeMatcher(ServerWebExchangeMatchers.pathMatchers(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 bca59ef2f29..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 @@ -18,21 +18,63 @@ package org.springframework.security.config.annotation.web.headers import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer +import org.springframework.security.web.util.matcher.RequestMatcher /** * A Kotlin DSL to configure the [HttpSecurity] Content-Security-Policy header using * idiomatic Kotlin code. * * @author Eleftheria Stein + * @author Ziqin Wang * @since 5.3 - * @property policyDirectives the security policy directive(s) to be used in the response header. - * @property reportOnly includes the Content-Security-Policy-Report-Only header in the response. */ @HeadersSecurityMarker class ContentSecurityPolicyDsl { + /** + * The security policy directive(s) to be used in the response header. + * The [policyDirectives] may contain `{nonce}` as placeholders for a generated secure + * random nonce, e.g., `script-src 'self' 'nonce-{nonce}'`. + */ var policyDirectives: String? = null + + /** Includes the `Content-Security-Policy-Report-Only` header in the response. */ var reportOnly: Boolean? = null + /** + * The name of the servlet request attribute for the generated nonce. Views can read + * this attribute to render the nonce in HTML. + * @since 7.2 + */ + var nonceAttributeName: String? = null + + /** + * The [RequestMatcher] to use for determining when CSP should be applied. + * 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 [requestMatchers], but not both. + * @since 7.2 + * @see requestMatchers + */ + var requestMatcher: RequestMatcher? = null + + private var requireCspPathPatterns: Array? = null + + /** + * Specify the matching path patterns for determining when CSP should be applied. + * 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 [requestMatcher], but not both. + * @param pathPatterns the path patterns to be matched with a + * [org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher] + * @since 7.2 + * @see requestMatcher + */ + fun requestMatchers(vararg pathPatterns: String) { + requireCspPathPatterns = pathPatterns + } + internal fun get(): (HeadersConfigurer.ContentSecurityPolicyConfig) -> Unit { return { contentSecurityPolicy -> policyDirectives?.also { @@ -43,6 +85,9 @@ class ContentSecurityPolicyDsl { contentSecurityPolicy.reportOnly() } } + nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName) + 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 fe524813f29..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 @@ -16,18 +16,61 @@ package org.springframework.security.config.web.server +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher + /** * A Kotlin DSL to configure the [ServerHttpSecurity] Content-Security-Policy header using * idiomatic Kotlin code. * * @author Eleftheria Stein + * @author Ziqin Wang * @since 5.4 */ @ServerSecurityMarker class ServerContentSecurityPolicyDsl { + /** + * The security policy directive(s) to be used in the response header. + * The `policyDirectives` may contain `{nonce}` as placeholders for a generated + * secure random nonce, e.g., `script-src 'self' 'nonce-{nonce}'`. + */ var policyDirectives: String? = null + + /** Includes the `Content-Security-Policy-Report-Only` header in the response. */ var reportOnly: Boolean? = null + /** + * The name of the request attribute for the generated nonce. Views can read this + * attribute to render the nonce in HTML. + * @since 7.2 + */ + var nonceAttributeName: String? = null + + /** + * 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 [exchangeMatchers], but not both. + * @since 7.2 + * @see exchangeMatchers + */ + var exchangeMatcher: ServerWebExchangeMatcher? = null + + private var requireCspPathPatterns: Array? = null + + /** + * 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 [exchangeMatcher], but not both. + * @param pathPatterns the path patterns to be matched with a + * [org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher] + * @since 7.2 + * @see exchangeMatcher + */ + fun exchangeMatchers(vararg pathPatterns: String) { + requireCspPathPatterns = pathPatterns + } + internal fun get(): (ServerHttpSecurity.HeaderSpec.ContentSecurityPolicySpec) -> Unit { return { contentSecurityPolicy -> policyDirectives?.also { @@ -36,6 +79,9 @@ class ServerContentSecurityPolicyDsl { reportOnly?.also { contentSecurityPolicy.reportOnly(reportOnly!!) } + nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName) + 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 b3c80e004c0..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,6 +19,7 @@ import java.net.URI; import java.util.LinkedHashMap; import java.util.Map; +import java.util.regex.Pattern; import com.google.common.net.HttpHeaders; import org.junit.jupiter.api.Test; @@ -28,26 +29,35 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 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; import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter; import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter.XFrameOptionsMode; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.stereotype.Controller; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultMatcher; +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.assertThatException; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.springframework.security.config.Customizer.withDefaults; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; /** @@ -61,6 +71,7 @@ * @author Eleftheria Stein * @author Marcus Da Coregio * @author Daniel Garnier-Moiroux + * @author Ziqin Wang */ @ExtendWith(SpringTestContextExtension.class) public class HeadersConfigurerTests { @@ -415,6 +426,69 @@ public void configureWhenContentSecurityPolicyNoPolicyDirectivesInLambdaThenDefa assertThat(mvcResult.getResponse().getHeaderNames()).containsExactly(HttpHeaders.CONTENT_SECURITY_POLICY); } + @Test + public void configureWhenContentSecurityPolicyWithDefaultNonceThenHeaderMatchesContent() throws Exception { + Pattern regex = Pattern.compile("^script-src 'self' 'nonce-([A-Za-z0-9+/]{22,}={0,2})'$"); + this.spring.register(ContentSecurityPolicyDefaultNonceConfig.class, TestCspNonceController.class).autowire(); + this.mvc.perform(get("/").secure(true)) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andExpect((result) -> { + String header = result.getResponse().getHeader(HttpHeaders.CONTENT_SECURITY_POLICY); + String content = result.getResponse().getContentAsString(); + assertThat(header).matchesSatisfying(regex, (matcher) -> { + String nonce = matcher.group(1); + assertThat(nonce).isNotNull(); + assertThat(content).contains("nonce=\"" + nonce + "\""); + }); + }); + } + + @Test + public void configureWhenContentSecurityPolicyWithCustomNonceThenHeaderMatchesContent() throws Exception { + Pattern regex = Pattern.compile("^script-src 'self' 'nonce-([A-Za-z0-9+/]{22,}={0,2})'$"); + this.spring.register(ContentSecurityPolicyCustomNonceConfig.class, TestCspNonceController.class).autowire(); + this.mvc.perform(get("/custom").secure(true)) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andExpect((result) -> { + String header = result.getResponse().getHeader(HttpHeaders.CONTENT_SECURITY_POLICY); + String content = result.getResponse().getContentAsString(); + assertThat(header).matchesSatisfying(regex, (matcher) -> { + String nonce = matcher.group(1); + assertThat(nonce).isNotNull(); + assertThat(content).contains("nonce=\"" + nonce + "\""); + }); + }); + } + + @Test + public void configureWhenContentSecurityPolicyWithMatcherThenHeaderInResponseIfMatched() throws Exception { + this.spring.register(ContentSecurityPolicyMatcherConfig.class).autowire(); + this.mvc.perform(get("/").secure(true).accept(MediaType.TEXT_HTML)) + .andExpect(header().string(HttpHeaders.CONTENT_SECURITY_POLICY, "default-src 'self'")); + this.mvc.perform(get("/").secure(true).accept(MediaType.TEXT_PLAIN)) + .andExpect(header().doesNotExist(HttpHeaders.CONTENT_SECURITY_POLICY)); + } + + @Test + public void configureWhenContentSecurityPolicyWithPathMatchersThenHeaderInResponseIfMatched() throws Exception { + this.spring.register(ContentSecurityPolicyPathMatchersConfig.class).autowire(); + this.mvc.perform(get("/foo/bar").secure(true)) + .andExpect(header().string(HttpHeaders.CONTENT_SECURITY_POLICY, "default-src 'self'")); + this.mvc.perform(get("/bar/foo").secure(true)) + .andExpect(header().string(HttpHeaders.CONTENT_SECURITY_POLICY, "default-src 'self'")); + this.mvc.perform(get("/foobar").secure(true)) + .andExpect(header().doesNotExist(HttpHeaders.CONTENT_SECURITY_POLICY)); + } + + @Test + public void configureWhenContentSecurityPolicyWithOverriddenMatchersThenThrows() { + assertThatException() + .isThrownBy(() -> this.spring.register(ContentSecurityPolicyOverriddenMatchersConfig.class).autowire()) + .havingRootCause() + .isInstanceOf(IllegalStateException.class) + .withMessage("RequestMatcher(s) is already configured"); + } + @Test public void getWhenReferrerPolicyConfiguredThenReferrerPolicyHeaderInResponse() throws Exception { this.spring.register(ReferrerPolicyDefaultConfig.class).autowire(); @@ -1094,6 +1168,133 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } + @Controller + static class TestCspNonceController { + + @GetMapping(produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + String defaultAttribute(@RequestAttribute("_csp") ContentSecurityPolicyNonce cspNonce) { + return """ + + + + Default + + """.formatted(cspNonce.getNonce()); + } + + @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + String custom(@RequestAttribute("CUSTOM_NONCE") ContentSecurityPolicyNonce cspNonce) { + return """ + + + + Custom + + """.formatted(cspNonce.getNonce()); + } + + } + + @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'") + .requestMatcher((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'") + .requestMatchers("/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'") + .requestMatcher(AnyRequestMatcher.INSTANCE) + .requestMatchers("/**"))); + // @formatter:on + return http.build(); + } + + } + @Configuration @EnableWebSecurity static class ReferrerPolicyDefaultConfig { diff --git a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java index b4c0467c467..4b0e250b6b3 100644 --- a/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java +++ b/config/src/test/java/org/springframework/security/config/http/HttpHeadersConfigTests.java @@ -44,6 +44,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.matchesRegex; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -708,6 +709,16 @@ public void configureWhenContentSecurityPolicyConfiguredWithEmptyDirectivesThenA () -> this.spring.configLocations(this.xml("ContentSecurityPolicyWithEmptyDirectives")).autowire()); } + @Test + void requestWhenContentSecurityPolicyDirectivesConfiguredWithNoncePlaceholderThenHeaderContainsNonce() + throws Exception { + this.spring.configLocations(this.xml("ContentSecurityPolicyWithNonceBasedDirectives")).autowire(); + this.mvc.perform(get("/").secure(true)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Security-Policy", + matchesRegex("^script-src 'self' 'nonce-[A-Za-z0-9+/]{22,}={0,2}'$"))); + } + @Test public void requestWhenContentSecurityPolicyConfiguredWithReportOnlyThenIncludesReportOnlyHeader() throws Exception { 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..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 @@ -19,14 +19,17 @@ 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.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; @@ -38,10 +41,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 +62,7 @@ * @author Vedran Pavic * @author Ankur Pathak * @author Marcus Da Coregio + * @author Ziqin Wang * @since 5.0 */ public class HeaderSpecTests { @@ -390,6 +402,130 @@ public void headersWhenContentSecurityPolicyEnabledInLambdaThenContentSecurityPo assertHeaders(); } + @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 + "\""); + }); + }); + } + + @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 + "\""); + }); + }); + } + + @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 + .exchangeMatcher((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 + } + + @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 + .exchangeMatchers("/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 + } + + @Test + public void headersWhenContentSecurityPolicyWithOverriddenMatchersThenFailToConfigure() { + // @formatter:off + assertThatIllegalStateException() + .isThrownBy(() -> this.http + .headers((headers) -> headers + .contentSecurityPolicy((csp) -> csp + .exchangeMatcher(ServerWebExchangeMatchers.anyExchange()) + .exchangeMatchers("/**") + .policyDirectives("default-src 'self'")))) + .withMessage("ExchangeMatcher(s) is already configured"); + // @formatter:on + } + @Test public void headersWhenReferrerPolicyEnabledThenFeaturePolicyWritten() { this.expectedHeaders.add(ReferrerPolicyServerHttpHeadersWriter.REFERRER_POLICY, @@ -525,4 +661,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") Mono cspNonce) { + return cspNonce.map(ContentSecurityPolicyNonce::getNonce).map(""" + + + + Default + + """::formatted); + } + + @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + Mono custom(@RequestAttribute("CUSTOM_NONCE") Mono cspNonce) { + return cspNonce.map(ContentSecurityPolicyNonce::getNonce).map(""" + + + + Custom + + """::formatted); + } + + } + } 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..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 @@ -16,25 +16,34 @@ 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.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] * * @author Eleftheria Stein + * @author Ziqin Wang */ @ExtendWith(SpringTestContextExtension::class) class ContentSecurityPolicyDslTests { @@ -51,7 +60,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, "default-src 'self'") } + header { string(CONTENT_SECURITY_POLICY, "default-src 'self'") } } } @@ -77,7 +86,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY, "default-src 'self'; script-src trustedscripts.example.com") } + header { string(CONTENT_SECURITY_POLICY, "default-src 'self'; script-src trustedscripts.example.com") } } } @@ -105,7 +114,7 @@ class ContentSecurityPolicyDslTests { this.mockMvc.get("/") { secure = true }.andExpect { - header { string(ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY_REPORT_ONLY, "default-src 'self'") } + header { string(CONTENT_SECURITY_POLICY_REPORT_ONLY, "default-src 'self'") } } } @@ -125,4 +134,177 @@ class ContentSecurityPolicyDslTests { return http.build() } } + + @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(CONTENT_SECURITY_POLICY, 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() + } + } + + @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(CONTENT_SECURITY_POLICY, 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() + } + } + + @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(CONTENT_SECURITY_POLICY, "default-src 'self'") + } + } + this.mockMvc.get("/") { + secure = true + accept = MediaType.TEXT_PLAIN + }.andExpect { + header { doesNotExist(CONTENT_SECURITY_POLICY) } + } + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyMatcherConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requestMatcher = RequestMatcher { request -> + val accepted = MediaType.parseMediaTypes(request.getHeader(HttpHeaders.ACCEPT)) + MediaType.TEXT_HTML.isPresentIn(accepted) + } + } + } + } + return http.build() + } + } + + @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(CONTENT_SECURITY_POLICY, "default-src 'self'") + } + } + this.mockMvc.get("/bar/foo") { + secure = true + }.andExpect { + header { + string(CONTENT_SECURITY_POLICY, "default-src 'self'") + } + } + this.mockMvc.get("/foobar") { + secure = true + }.andExpect { + header { doesNotExist(CONTENT_SECURITY_POLICY) } + } + } + + @Configuration + @EnableWebSecurity + open class ContentSecurityPolicyPathMatchersConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + headers { + defaultsDisabled = true + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + requestMatchers("/foo/**", "/bar/**") + } + } + } + return http.build() + } + } + + @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("RequestMatcher(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'" + requestMatcher = AnyRequestMatcher.INSTANCE + requestMatchers("/**") + } + } + } + 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..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 @@ -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,155 @@ class ServerContentSecurityPolicyDslTests { } } } + + @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}'" + } + } + } + } + } + + @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" + } + } + } + } + } + + @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'" + exchangeMatcher = ServerWebExchangeMatcher { exchange -> + if (MediaType.TEXT_HTML.isPresentIn(exchange.request.headers.accept)) + ServerWebExchangeMatcher.MatchResult.match() + else + ServerWebExchangeMatcher.MatchResult.notMatch() + } + } + } + } + } + + @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'" + exchangeMatchers("/foo/**", "/bar/**") + } + } + } + } + + @Test + fun `when matchers overridden then fails to configure`() { + assertThatThrownBy { + this.spring.register(CspPathOverriddenMatchersConfig::class.java).autowire() + }.hasRootCauseInstanceOf(IllegalStateException::class.java) + .hasRootCauseMessage("ExchangeMatcher(s) is already configured") + } + + @Configuration + @EnableWebFluxSecurity + @EnableWebFlux + open class CspPathOverriddenMatchersConfig { + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http { + headers { + contentSecurityPolicy { + policyDirectives = "default-src 'self'" + exchangeMatcher = ServerWebExchangeMatchers.anyExchange() + exchangeMatchers("/**") + } + } + } + } + } diff --git a/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-ContentSecurityPolicyWithNonceBasedDirectives.xml b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-ContentSecurityPolicyWithNonceBasedDirectives.xml new file mode 100644 index 00000000000..7a61616c7df --- /dev/null +++ b/config/src/test/resources/org/springframework/security/config/http/HttpHeadersConfigTests-ContentSecurityPolicyWithNonceBasedDirectives.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..2925772f4a9 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -0,0 +1,114 @@ +/* + * 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; + +import java.io.IOException; +import java.util.Base64; +import java.util.function.Supplier; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +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; + +/** + * 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} + * can use the attribute to write a nonce-based Content Security Policy header, and a view + * technology can render the nonce in generated HTML to allow intended inline + * {@code