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..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 @@ -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; @@ -35,6 +38,7 @@ 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; @@ -45,6 +49,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 +81,7 @@ * @author Vedran Pavic * @author Ankur Pathak * @author Daniel Garnier-Moiroux + * @author Ziqin Wang * @since 3.2 */ public class HeadersConfigurer> @@ -273,6 +280,7 @@ public HeadersConfigurer defaultsDisabled() { public void configure(H http) { HeaderWriterFilter headersFilter = createHeaderWriterFilter(); http.addFilter(headersFilter); + http.addFilterBefore(this.contentSecurityPolicy.getNonceGeneratingFilter(), HeaderWriterFilter.class); } /** @@ -302,7 +310,7 @@ private List getHeaderWriters() { addIfNotNull(writers, this.hsts.writer); addIfNotNull(writers, this.frameOptions.writer); addIfNotNull(writers, this.hpkp.writer); - addIfNotNull(writers, this.contentSecurityPolicy.writer); + addIfNotNull(writers, this.contentSecurityPolicy.getWriter()); addIfNotNull(writers, this.referrerPolicy.writer); addIfNotNull(writers, this.featurePolicy.writer); addIfNotNull(writers, this.permissionsPolicy.writer); @@ -937,11 +945,17 @@ public final class ContentSecurityPolicyConfig { private ContentSecurityPolicyHeaderWriter writer; + private @Nullable String nonceAttributeName; + + 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 +975,75 @@ 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.1 + */ + public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) { + Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); + this.nonceAttributeName = 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.1 + * @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.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.1 + * @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); + } + + 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 6dff6f207e7..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 @@ -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; @@ -183,6 +185,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; @@ -197,6 +200,7 @@ 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; @@ -2452,13 +2456,12 @@ protected void configure(ServerHttpSecurity http) { * Configures HTTP Response Headers. * * @author Rob Winch + * @author Ziqin Wang * @since 5.0 * @see #headers(Customizer) */ public final class HeaderSpec { - private final List writers; - private CacheControlServerHttpHeadersWriter cacheControl = new CacheControlServerHttpHeadersWriter(); private ContentTypeOptionsServerHttpHeadersWriter contentTypeOptions = new ContentTypeOptionsServerHttpHeadersWriter(); @@ -2473,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(); @@ -2483,11 +2486,9 @@ public final class HeaderSpec { private CrossOriginResourcePolicyServerHttpHeadersWriter crossOriginResourcePolicy = new CrossOriginResourcePolicyServerHttpHeadersWriter(); + 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)); } /** @@ -2541,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; } @@ -2557,9 +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); + http.addFilterBefore(this.contentSecurityPolicy.getNonceGeneratingFilter(), + SecurityWebFiltersOrder.HTTP_HEADERS_WRITER); } /** @@ -2580,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; } @@ -2675,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; } @@ -2696,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; } @@ -2728,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; } @@ -2787,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; } @@ -2808,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; } @@ -2836,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); } /** @@ -2846,24 +2862,123 @@ 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) { - HeaderSpec.this.contentSecurityPolicy.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 + * 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 + * @deprecated Use {@link #directives(String)} instead */ + @Deprecated(since = "7.1") public HeaderSpec policyDirectives(String policyDirectives) { - HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); + this.directives(policyDirectives); return HeaderSpec.this; } - private ContentSecurityPolicySpec(String policyDirectives) { - HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives); + /** + * 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 + * 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.1 + */ + public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) { + Assert.hasLength(nonceAttributeName, "NonceAttributeName must not be null or empty"); + this.nonceAttributeName = 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.1 + * @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"); + 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.1 + * @see #exchangeMatcher(ServerWebExchangeMatcher) + */ + 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/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..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 @@ -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.1 + */ + 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.1 + * @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.1 + * @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..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 @@ -16,26 +16,70 @@ 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.1 + */ + 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.1 + * @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.1 + * @see exchangeMatcher + */ + fun exchangeMatchers(vararg pathPatterns: String) { + requireCspPathPatterns = pathPatterns + } + internal fun get(): (ServerHttpSecurity.HeaderSpec.ContentSecurityPolicySpec) -> Unit { return { contentSecurityPolicy -> - policyDirectives?.also { - contentSecurityPolicy.policyDirectives(policyDirectives) - } - reportOnly?.also { - contentSecurityPolicy.reportOnly(reportOnly!!) + policyDirectives?.also(contentSecurityPolicy::directives) + if (reportOnly == true) { + contentSecurityPolicy.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..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 @@ -19,6 +19,8 @@ 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; import org.junit.jupiter.api.Test; @@ -28,6 +30,7 @@ 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; @@ -40,14 +43,21 @@ 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_nonce") Supplier cspNonce) { + return """ + + + + Default + + """.formatted(cspNonce.get()); + } + + @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + String custom(@RequestAttribute("CUSTOM_NONCE") Supplier cspNonce) { + return """ + + + + Custom + + """.formatted(cspNonce.get()); + } + + } + + @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/web/server/HeaderSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderSpecTests.java index 7fe1052f323..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 @@ -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 { @@ -357,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(); } @@ -382,14 +393,138 @@ public void headersWhenContentSecurityPolicyEnabledInLambdaThenContentSecurityPo policyDirectives); // @formatter:off this.http.headers((headers) -> headers - .contentSecurityPolicy((csp) -> csp - .policyDirectives(policyDirectives) - ) + .contentSecurityPolicy((csp) -> csp + .directives(policyDirectives) + ) ); // @formatter:on 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 + .directives("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") + .directives("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())) + .directives(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/**") + .directives(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("/**") + .directives("default-src 'self'")))) + .withMessage("ExchangeMatcher(s) is already configured"); + // @formatter:on + } + @Test public void headersWhenReferrerPolicyEnabledThenFeaturePolicyWritten() { this.expectedHeaders.add(ReferrerPolicyServerHttpHeadersWriter.REFERRER_POLICY, @@ -525,4 +660,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") Mono cspNonce) { + return cspNonce.map(""" + + + + Default + + """::formatted); + } + + @GetMapping(path = "/custom", produces = MediaType.TEXT_HTML_VALUE) + @ResponseBody + Mono custom(@RequestAttribute("CUSTOM_NONCE") Mono cspNonce) { + return cspNonce.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..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 @@ -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,182 @@ 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(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() + } + } + + @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() + } + } + + @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'" + 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(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'" + 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/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 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..e1f9044cfdf --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/header/ContentSecurityPolicyNonceGeneratingFilter.java @@ -0,0 +1,99 @@ +/* + * 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 nonce string 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