Skip to content

Commit 0ecdc80

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 a91d888 commit 0ecdc80

8 files changed

Lines changed: 976 additions & 8 deletions

File tree

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

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
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;
2324

2425
import jakarta.servlet.http.HttpServletRequest;
26+
import org.jspecify.annotations.Nullable;
2527

2628
import org.springframework.security.config.Customizer;
2729
import org.springframework.security.config.annotation.configuration.ObjectPostProcessorConfiguration;
@@ -30,11 +32,13 @@
3032
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
3133
import org.springframework.security.web.header.HeaderWriter;
3234
import org.springframework.security.web.header.HeaderWriterFilter;
35+
import org.springframework.security.web.header.NonceGeneratingFilter;
3336
import org.springframework.security.web.header.writers.CacheControlHeadersWriter;
3437
import org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter;
3538
import org.springframework.security.web.header.writers.CrossOriginEmbedderPolicyHeaderWriter;
3639
import org.springframework.security.web.header.writers.CrossOriginOpenerPolicyHeaderWriter;
3740
import org.springframework.security.web.header.writers.CrossOriginResourcePolicyHeaderWriter;
41+
import org.springframework.security.web.header.writers.DelegatingRequestMatcherHeaderWriter;
3842
import org.springframework.security.web.header.writers.FeaturePolicyHeaderWriter;
3943
import org.springframework.security.web.header.writers.HpkpHeaderWriter;
4044
import org.springframework.security.web.header.writers.HstsHeaderWriter;
@@ -45,6 +49,8 @@
4549
import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter;
4650
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
4751
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter.XFrameOptionsMode;
52+
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
53+
import org.springframework.security.web.util.matcher.OrRequestMatcher;
4854
import org.springframework.security.web.util.matcher.RequestMatcher;
4955
import org.springframework.util.Assert;
5056

@@ -75,6 +81,7 @@
7581
* @author Vedran Pavic
7682
* @author Ankur Pathak
7783
* @author Daniel Garnier-Moiroux
84+
* @author Ziqin Wang
7885
* @since 3.2
7986
*/
8087
public class HeadersConfigurer<H extends HttpSecurityBuilder<H>>
@@ -273,6 +280,14 @@ public HeadersConfigurer<H> defaultsDisabled() {
273280
public void configure(H http) {
274281
HeaderWriterFilter headersFilter = createHeaderWriterFilter();
275282
http.addFilter(headersFilter);
283+
configureCspNonceGeneratingFilter(http);
284+
}
285+
286+
private void configureCspNonceGeneratingFilter(H http) {
287+
ContentSecurityPolicyHeaderWriter writer = this.contentSecurityPolicy.writer;
288+
if (writer != null && writer.isNonceBased()) {
289+
http.addFilterBefore(new NonceGeneratingFilter(writer.getNonceAttributeName()), HeaderWriterFilter.class);
290+
}
276291
}
277292

278293
/**
@@ -302,7 +317,7 @@ private List<HeaderWriter> getHeaderWriters() {
302317
addIfNotNull(writers, this.hsts.writer);
303318
addIfNotNull(writers, this.frameOptions.writer);
304319
addIfNotNull(writers, this.hpkp.writer);
305-
addIfNotNull(writers, this.contentSecurityPolicy.writer);
320+
addIfNotNull(writers, this.contentSecurityPolicy.getWriter());
306321
addIfNotNull(writers, this.referrerPolicy.writer);
307322
addIfNotNull(writers, this.featurePolicy.writer);
308323
addIfNotNull(writers, this.permissionsPolicy.writer);
@@ -937,11 +952,15 @@ public final class ContentSecurityPolicyConfig {
937952

938953
private ContentSecurityPolicyHeaderWriter writer;
939954

955+
private @Nullable RequestMatcher requestMatcher;
956+
940957
private ContentSecurityPolicyConfig() {
941958
}
942959

943960
/**
944-
* Sets the security policy directive(s) to be used in the response header.
961+
* Sets the security policy directive(s) to be used in the response header. The
962+
* {@code policyDirectives} may contain {@code {nonce}} as placeholders for a
963+
* generated secure random nonce, e.g., {@code script-src 'self' 'nonce-{nonce}'}.
945964
* @param policyDirectives the security policy directive(s)
946965
* @return the {@link ContentSecurityPolicyConfig} for additional configuration
947966
* @throws IllegalArgumentException if policyDirectives is null or empty
@@ -961,6 +980,66 @@ public ContentSecurityPolicyConfig reportOnly() {
961980
return this;
962981
}
963982

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

9661045
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)