Skip to content

Commit c7799ea

Browse files
committed
Support configuring nonce-based Content-Security-Policy
This commit adds support for configuring nonce-based CSP with Java lambda or Kotlin DSL. By default, Spring Security adds protection headers for all served resources. This was not a problem in the past because header values were static. However, with the introduction of a dynamic nonce in the CSP, the caching property of static asserts served may change. With this in mind, this commit also adds convenient methods to set a request matcher to determine whether a request requires CSP protection or not. Closes gh-10826 Signed-off-by: Ziqin Wang <ziqin@wangziqin.net>
1 parent 8da8283 commit c7799ea

8 files changed

Lines changed: 981 additions & 8 deletions

File tree

config/src/main/java/org/springframework/security/config/annotation/web/configurers/HeadersConfigurer.java

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,13 @@
1818

1919
import java.net.URI;
2020
import java.util.ArrayList;
21+
import java.util.Arrays;
2122
import java.util.List;
2223
import java.util.Map;
24+
import java.util.function.Function;
2325

2426
import jakarta.servlet.http.HttpServletRequest;
27+
import org.jspecify.annotations.Nullable;
2528

2629
import org.springframework.security.config.Customizer;
2730
import org.springframework.security.config.annotation.configuration.ObjectPostProcessorConfiguration;
@@ -30,11 +33,13 @@
3033
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
3134
import org.springframework.security.web.header.HeaderWriter;
3235
import org.springframework.security.web.header.HeaderWriterFilter;
36+
import org.springframework.security.web.header.NonceGeneratingFilter;
3337
import org.springframework.security.web.header.writers.CacheControlHeadersWriter;
3438
import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter;
3539
import org.springframework.security.web.header.writers.CrossOriginEmbedderPolicyHeaderWriter;
3640
import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter;
3741
import org.springframework.security.web.header.writers.CrossOriginResourcePolicyHeaderWriter;
42+
import org.springframework.security.web.header.writers.DelegatingRequestMatcherHeaderWriter;
3843
import org.springframework.security.web.header.writers.FeaturePolicyHeaderWriter;
3944
import org.springframework.security.web.header.writers.HpkpHeaderWriter;
4045
import org.springframework.security.web.header.writers.HstsHeaderWriter;
@@ -45,6 +50,8 @@
4550
import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter;
4651
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
4752
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter.XFrameOptionsMode;
53+
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
54+
import org.springframework.security.web.util.matcher.OrRequestMatcher;
4855
import org.springframework.security.web.util.matcher.RequestMatcher;
4956
import org.springframework.util.Assert;
5057

@@ -75,6 +82,7 @@
7582
* @author Vedran Pavic
7683
* @author Ankur Pathak
7784
* @author Daniel Garnier-Moiroux
85+
* @author Ziqin Wang
7886
* @since 3.2
7987
*/
8088
public class HeadersConfigurer<H extends HttpSecurityBuilder<H>>
@@ -273,6 +281,14 @@ public HeadersConfigurer<H> defaultsDisabled() {
273281
public void configure(H http) {
274282
HeaderWriterFilter headersFilter = createHeaderWriterFilter();
275283
http.addFilter(headersFilter);
284+
configureCspNonceGeneratingFilter(http);
285+
}
286+
287+
private void configureCspNonceGeneratingFilter(H http) {
288+
ContentSecurityPolicyHeaderWriter writer = this.contentSecurityPolicy.writer;
289+
if (writer != null && writer.isNonceBased()) {
290+
http.addFilterBefore(new NonceGeneratingFilter(writer.getNonceAttributeName()), HeaderWriterFilter.class);
291+
}
276292
}
277293

278294
/**
@@ -302,7 +318,7 @@ private List<HeaderWriter> getHeaderWriters() {
302318
addIfNotNull(writers, this.hsts.writer);
303319
addIfNotNull(writers, this.frameOptions.writer);
304320
addIfNotNull(writers, this.hpkp.writer);
305-
addIfNotNull(writers, this.contentSecurityPolicy.writer);
321+
addIfNotNull(writers, this.contentSecurityPolicy.getWriter());
306322
addIfNotNull(writers, this.referrerPolicy.writer);
307323
addIfNotNull(writers, this.featurePolicy.writer);
308324
addIfNotNull(writers, this.permissionsPolicy.writer);
@@ -937,11 +953,15 @@ public final class ContentSecurityPolicyConfig {
937953

938954
private ContentSecurityPolicyHeaderWriter writer;
939955

956+
private @Nullable RequestMatcher requestMatcher;
957+
940958
private ContentSecurityPolicyConfig() {
941959
}
942960

943961
/**
944-
* Sets the security policy directive(s) to be used in the response header.
962+
* Sets the security policy directive(s) to be used in the response header. The
963+
* {@code policyDirectives} may contain {@code {nonce}} as placeholders for a
964+
* generated secure random nonce, e.g., {@code script-src 'self' 'nonce-{nonce}'}.
945965
* @param policyDirectives the security policy directive(s)
946966
* @return the {@link ContentSecurityPolicyConfig} for additional configuration
947967
* @throws IllegalArgumentException if policyDirectives is null or empty
@@ -961,6 +981,70 @@ public ContentSecurityPolicyConfig reportOnly() {
961981
return this;
962982
}
963983

984+
/**
985+
* Sets the name of the servlet request attribute for the generated nonce. Views
986+
* can read this attribute to render the nonce in HTML.
987+
* @param nonceAttributeName the name of the nonce attribute
988+
* @return the {@link ContentSecurityPolicyConfig} for additional configuration
989+
* @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null}
990+
* or empty
991+
* @since 7.1
992+
*/
993+
public ContentSecurityPolicyConfig nonceAttributeName(String nonceAttributeName) {
994+
this.writer.setNonceAttributeName(nonceAttributeName);
995+
return this;
996+
}
997+
998+
/**
999+
* Specifies the {@link RequestMatcher} to use for determining when CSP should be
1000+
* applied. The default is to enable CSP in every response if
1001+
* {@link HeadersConfigurer#contentSecurityPolicy(Customizer)} is configured.
1002+
* @param requestMatcher the {@link RequestMatcher} to use
1003+
* @return the {@link ContentSecurityPolicyConfig} for additional configuration
1004+
* @throws IllegalArgumentException if {@code requestMatcher} is null
1005+
* @throws IllegalStateException if a {@link RequestMatcher} is already configured
1006+
* by a previous call of this method or {@link #requireCspMatchers(String...)}
1007+
* @since 7.1
1008+
* @see #requireCspMatchers(String...)
1009+
*/
1010+
public ContentSecurityPolicyConfig requireCspMatcher(RequestMatcher requestMatcher) {
1011+
Assert.notNull(requestMatcher, "RequestMatcher cannot be null");
1012+
Assert.state(this.requestMatcher == null, "RequireCspMatcher(s) is already configured");
1013+
this.requestMatcher = requestMatcher;
1014+
return this;
1015+
}
1016+
1017+
/**
1018+
* Specifies the matching path patterns for determining when CSP should be
1019+
* applied. The default is to enable CSP in every response if
1020+
* {@link HeadersConfigurer#contentSecurityPolicy(Customizer)} is configured.
1021+
* @param pathPatterns the path patterns to be matched with a
1022+
* {@link PathPatternRequestMatcher}
1023+
* @return the {@link ContentSecurityPolicyConfig} for additional configuration
1024+
* @throws IllegalArgumentException if any path pattern if rejected by
1025+
* {@link PathPatternRequestMatcher.Builder#matcher(String)}
1026+
* @throws IllegalStateException if a {@link RequestMatcher} is already configured
1027+
* by a previous call of this method or {@link #requireCspMatcher(RequestMatcher)}
1028+
* @since 7.1
1029+
* @see #requireCspMatcher(RequestMatcher)
1030+
*/
1031+
public ContentSecurityPolicyConfig requireCspMatchers(String... pathPatterns) {
1032+
PathPatternRequestMatcher.Builder builder = HeadersConfigurer.this.getRequestMatcherBuilder();
1033+
// @formatter:off
1034+
return this.requireCspMatcher(new OrRequestMatcher(
1035+
Arrays.stream(pathPatterns)
1036+
.map((Function<? super String, RequestMatcher>) builder::matcher)
1037+
.toList()));
1038+
// @formatter:on
1039+
}
1040+
1041+
HeaderWriter getWriter() {
1042+
if (this.requestMatcher != null) {
1043+
return new DelegatingRequestMatcherHeaderWriter(this.requestMatcher, this.writer);
1044+
}
1045+
return this.writer;
1046+
}
1047+
9641048
}
9651049

9661050
public final class ReferrerPolicyConfig {

config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,12 @@
193193
import org.springframework.security.web.server.header.CrossOriginResourcePolicyServerHttpHeadersWriter.CrossOriginResourcePolicy;
194194
import org.springframework.security.web.server.header.FeaturePolicyServerHttpHeadersWriter;
195195
import org.springframework.security.web.server.header.HttpHeaderWriterWebFilter;
196+
import org.springframework.security.web.server.header.NonceGeneratingWebFilter;
196197
import org.springframework.security.web.server.header.PermissionsPolicyServerHttpHeadersWriter;
197198
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter;
198199
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
199200
import org.springframework.security.web.server.header.ServerHttpHeadersWriter;
201+
import org.springframework.security.web.server.header.ServerWebExchangeDelegatingServerHttpHeadersWriter;
200202
import org.springframework.security.web.server.header.StrictTransportSecurityServerHttpHeadersWriter;
201203
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter;
202204
import org.springframework.security.web.server.header.XXssProtectionServerHttpHeadersWriter;
@@ -2452,6 +2454,7 @@ protected void configure(ServerHttpSecurity http) {
24522454
* Configures HTTP Response Headers.
24532455
*
24542456
* @author Rob Winch
2457+
* @author Ziqin Wang
24552458
* @since 5.0
24562459
* @see #headers(Customizer)
24572460
*/
@@ -2560,6 +2563,10 @@ protected void configure(ServerHttpSecurity http) {
25602563
ServerHttpHeadersWriter writer = new CompositeServerHttpHeadersWriter(this.writers);
25612564
HttpHeaderWriterWebFilter result = new HttpHeaderWriterWebFilter(writer);
25622565
http.addFilterAt(result, SecurityWebFiltersOrder.HTTP_HEADERS_WRITER);
2566+
if (this.contentSecurityPolicy.isNonceBased()) {
2567+
http.addFilterBefore(new NonceGeneratingWebFilter(this.contentSecurityPolicy.getNonceAttributeName()),
2568+
SecurityWebFiltersOrder.HTTP_HEADERS_WRITER);
2569+
}
25632570
}
25642571

25652572
/**
@@ -2854,6 +2861,9 @@ public ContentSecurityPolicySpec reportOnly(boolean reportOnly) {
28542861

28552862
/**
28562863
* Sets the security policy directive(s) to be used in the response header.
2864+
* The {@code policyDirectives} may contain {@code {nonce}} as placeholders
2865+
* for a generated secure random nonce, e.g., {@code script-src 'self'
2866+
* 'nonce-{nonce}'}.
28572867
* @param policyDirectives the security policy directive(s)
28582868
* @return the {@link ContentSecurityPolicySpec} to continue configuring
28592869
*/
@@ -2862,6 +2872,63 @@ public ContentSecurityPolicySpec policyDirectives(String policyDirectives) {
28622872
return this;
28632873
}
28642874

2875+
/**
2876+
* Sets the name of the {@link ServerWebExchange#getAttribute(String) request
2877+
* attribute} for the generated nonce. Views can read this attribute to render
2878+
* the nonce in HTML.
2879+
* @param nonceAttributeName the name of the nonce attribute
2880+
* @return the {@link ContentSecurityPolicySpec} to continue configuring
2881+
* @throws IllegalArgumentException if {@code nonceAttributeName} is
2882+
* {@code null} or empty
2883+
* @since 7.1
2884+
*/
2885+
public ContentSecurityPolicySpec nonceAttributeName(String nonceAttributeName) {
2886+
HeaderSpec.this.contentSecurityPolicy.setNonceAttributeName(nonceAttributeName);
2887+
return this;
2888+
}
2889+
2890+
/**
2891+
* Specifies the {@link ServerWebExchangeMatcher} to use for determining when
2892+
* CSP should be applied. The default is to enable CSP in every response if
2893+
* {@link HeaderSpec#contentSecurityPolicy(Customizer)} is configured.
2894+
* @param matcher the {@link ServerWebExchangeMatcher} to use
2895+
* @return the {@link ContentSecurityPolicySpec} to continue configuring
2896+
* @throws IllegalArgumentException if {@code matcher} is {@code null}
2897+
* @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is
2898+
* already configured by a previous call of this method or
2899+
* {@link #requireCspMatchers(String...)}
2900+
* @since 7.1
2901+
* @see #requireCspMatchers(String...)
2902+
*/
2903+
public ContentSecurityPolicySpec requireCspMatcher(ServerWebExchangeMatcher matcher) {
2904+
Assert.notNull(matcher, "Matcher must not be null");
2905+
// Replace the CSP writer in the list with a matcher-decorated writer
2906+
int idx = HeaderSpec.this.writers.indexOf(HeaderSpec.this.contentSecurityPolicy);
2907+
Assert.state(idx >= 0, "RequireCspMatcher(s) is already configured");
2908+
HeaderSpec.this.writers.set(idx, new ServerWebExchangeDelegatingServerHttpHeadersWriter(matcher,
2909+
HeaderSpec.this.contentSecurityPolicy));
2910+
return this;
2911+
}
2912+
2913+
/**
2914+
* Specifies the matching path patterns for determining when CSP should be
2915+
* applied. The default is to enable CSP in every response if
2916+
* {@link HeaderSpec#contentSecurityPolicy(Customizer)} is configured.
2917+
* @param pathPatterns the path patterns to be matched with a
2918+
* {@link PathPatternParserServerWebExchangeMatcher}
2919+
* @return the {@link ContentSecurityPolicySpec} to continue configuring
2920+
* @throws IllegalArgumentException if any path pattern is rejected by
2921+
* {@link PathPatternParserServerWebExchangeMatcher}
2922+
* @throws IllegalStateException if a {@link ServerWebExchangeMatcher} is
2923+
* already configured by a previous call of this method or
2924+
* {@link #requireCspMatcher(ServerWebExchangeMatcher)}
2925+
* @since 7.1
2926+
* @see #requireCspMatcher(ServerWebExchangeMatcher)
2927+
*/
2928+
public ContentSecurityPolicySpec requireCspMatchers(String... pathPatterns) {
2929+
return this.requireCspMatcher(ServerWebExchangeMatchers.pathMatchers(pathPatterns));
2930+
}
2931+
28652932
private ContentSecurityPolicySpec(String policyDirectives) {
28662933
HeaderSpec.this.contentSecurityPolicy.setPolicyDirectives(policyDirectives);
28672934
}

config/src/main/kotlin/org/springframework/security/config/annotation/web/headers/ContentSecurityPolicyDsl.kt

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,63 @@ package org.springframework.security.config.annotation.web.headers
1818

1919
import org.springframework.security.config.annotation.web.builders.HttpSecurity
2020
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer
21+
import org.springframework.security.web.util.matcher.RequestMatcher
2122

2223
/**
2324
* A Kotlin DSL to configure the [HttpSecurity] Content-Security-Policy header using
2425
* idiomatic Kotlin code.
2526
*
2627
* @author Eleftheria Stein
28+
* @author Ziqin Wang
2729
* @since 5.3
28-
* @property policyDirectives the security policy directive(s) to be used in the response header.
29-
* @property reportOnly includes the Content-Security-Policy-Report-Only header in the response.
3030
*/
3131
@HeadersSecurityMarker
3232
class ContentSecurityPolicyDsl {
33+
/**
34+
* The security policy directive(s) to be used in the response header.
35+
* The [policyDirectives] may contain `{code}` as placeholders for a generated secure
36+
* random nonce, e.g., `script-src 'self' 'nonce-{nonce}'`.
37+
*/
3338
var policyDirectives: String? = null
39+
40+
/** Includes the `Content-Security-Policy-Report-Only` header in the response. */
3441
var reportOnly: Boolean? = null
3542

43+
/**
44+
* The name of the servlet request attribute for the generated nonce. Views can read
45+
* this attribute to render the nonce in HTML.
46+
* @since 7.1
47+
*/
48+
var nonceAttributeName: String? = null
49+
50+
/**
51+
* The [RequestMatcher] to use for determining when CSP should be applied.
52+
* The default is to enable CSP in every response if
53+
* [org.springframework.security.config.annotation.web.HeadersDsl.contentSecurityPolicy]
54+
* is configured.
55+
* You can configure either this property or [requireCspMatchers], but not both.
56+
* @since 7.1
57+
* @see requireCspMatchers
58+
*/
59+
var requireCspMatcher: RequestMatcher? = null
60+
61+
private var requireCspPathPatterns: Array<out String>? = null
62+
63+
/**
64+
* Specify the matching path patterns for determining when CSP should be applied.
65+
* The default is to write CSP header in every response if
66+
* [org.springframework.security.config.annotation.web.HeadersDsl.contentSecurityPolicy]
67+
* is configured.
68+
* You can configure either this method or [requireCspMatcher], but not both.
69+
* @param pathPatterns the path patterns to be matched with a
70+
* [org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher]
71+
* @since 7.1
72+
* @see requireCspMatcher
73+
*/
74+
fun requireCspMatchers(vararg pathPatterns: String) {
75+
requireCspPathPatterns = pathPatterns
76+
}
77+
3678
internal fun get(): (HeadersConfigurer<HttpSecurity>.ContentSecurityPolicyConfig) -> Unit {
3779
return { contentSecurityPolicy ->
3880
policyDirectives?.also {
@@ -43,6 +85,9 @@ class ContentSecurityPolicyDsl {
4385
contentSecurityPolicy.reportOnly()
4486
}
4587
}
88+
nonceAttributeName?.also(contentSecurityPolicy::nonceAttributeName)
89+
requireCspMatcher?.also(contentSecurityPolicy::requireCspMatcher)
90+
requireCspPathPatterns?.also(contentSecurityPolicy::requireCspMatchers)
4691
}
4792
}
4893
}

0 commit comments

Comments
 (0)