Skip to content

Commit dc112ee

Browse files
committed
Support nonce-based Content-Security-Policy
When strict Content Security Policy is used, web browsers block inline <script> or <style> blocks in HTML to mitigate XSS attacks injecting malicious inline blocks. To allow intended inline blocks, web developers can generate a hard-to-guess nonce and specify it in both the CSP and allowed inline blocks. Currently, Spring Security only supports specifying static content security policy directives. This commit adds support to dynamically generate a secure random nonce for CSP: - NonceGeneratingFilter & NonceGeneratingWebFilter are added to generate a nonce and set it as a request attribute, - ContentSecurityPolicyHeaderWriter & ContentSecurityPolicyServerHttpHeadersWriter are modified to read the _csp_nonce attribute and write it to the Content-Security-Policy header, replacing the {nonce} placeholder in the given policyDirectives string. The whole process is separated in two steps because by default a header writer cannot set a request attribute visible to views for rendering the nonce in HTML. `_csp_nonce` is chosen as the default attribute name because it has a similar format with the existing `_csrf` attribute. The attribute name is configurable. This commit implements gh-10826. Signed-off-by: Ziqin Wang <ziqin@wangziqin.net>
1 parent 8320c76 commit dc112ee

8 files changed

Lines changed: 643 additions & 33 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.header;
18+
19+
import java.io.IOException;
20+
import java.util.Base64;
21+
22+
import jakarta.servlet.FilterChain;
23+
import jakarta.servlet.ServletException;
24+
import jakarta.servlet.http.HttpServletRequest;
25+
import jakarta.servlet.http.HttpServletResponse;
26+
27+
import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
28+
import org.springframework.security.crypto.keygen.StringKeyGenerator;
29+
import org.springframework.util.Assert;
30+
import org.springframework.web.filter.OncePerRequestFilter;
31+
32+
/**
33+
* A filter which generates a nonce string and sets it as a request attribute.
34+
*
35+
* <p>
36+
* {@link org.springframework.security.web.header.writers.ContentSecurityPolicyHeaderWriter}
37+
* can use the attribute to write a nonce-based Content Security Policy header, and a view
38+
* technology can render the nonce in generated HTML to allow intended inline
39+
* {@code <script>} or {@code <style>} blocks.
40+
*
41+
* <p>
42+
* This filter may be used to generate a nonce attribute for other purposes.
43+
*
44+
* @author Ziqin Wang
45+
* @since 7.1
46+
*/
47+
public final class NonceGeneratingFilter extends OncePerRequestFilter {
48+
49+
private final String attributeName;
50+
51+
private final StringKeyGenerator nonceGenerator;
52+
53+
/**
54+
* Creates a new instance.
55+
* @param attributeName the name of the request attribute to generate
56+
* @param nonceGenerator a {@link StringKeyGenerator} for generating nonce
57+
* @throws IllegalArgumentException if {@code attributeName} is null or empty string,
58+
* or {@code nonceGenerator} is null
59+
*/
60+
public NonceGeneratingFilter(String attributeName, StringKeyGenerator nonceGenerator) {
61+
Assert.hasLength(attributeName, "AttributeName must not be null or empty");
62+
Assert.notNull(nonceGenerator, "NonceGenerator must not be null");
63+
this.attributeName = attributeName;
64+
this.nonceGenerator = nonceGenerator;
65+
}
66+
67+
/**
68+
* Creates a new instance.
69+
* <p>
70+
* For each request, the created filter will generate a secure random nonce value with
71+
* 128-bit entropy and encode it as a Base64 string without padding.
72+
* @param attributeName the name of the request attribute to generate
73+
* @throws IllegalArgumentException if {@code attributeName} is null or empty string
74+
*/
75+
public NonceGeneratingFilter(String attributeName) {
76+
this(attributeName, new Base64StringKeyGenerator(Base64.getEncoder().withoutPadding(), 16));
77+
}
78+
79+
@Override
80+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
81+
throws ServletException, IOException {
82+
83+
String nonce = this.nonceGenerator.generateKey();
84+
request.setAttribute(this.attributeName, nonce);
85+
filterChain.doFilter(request, response);
86+
}
87+
88+
}

web/src/main/java/org/springframework/security/web/header/writers/ContentSecurityPolicyHeaderWriter.java

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,30 @@
5555
* </p>
5656
*
5757
* <p>
58+
* With related directives specified, web clients could block inline {@code <script>} or
59+
* {@code <style>} blocks in the HTML to mitigate XSS attacks injecting malicious inline
60+
* blocks. To allow intended inline blocks, a CSP directive (usually {@code script-src} or
61+
* {@code style-src}) may specify a hard-to-guess nonce matching the nonce attributes of
62+
* inline HTML blocks.
63+
* </p>
64+
*
65+
* <p>
66+
* To ease writing nonce-based CSP headers, this class replaces the {@code {nonce}}
67+
* placeholder in the {@code policyDirectives} with a real nonce value read from a servlet
68+
* request attribute named {@code _csp_nonce} (or another configured attribute name). A
69+
* {@link org.springframework.security.web.header.NonceGeneratingFilter} can be configured
70+
* to generate a unique secure random {@code _csp_nonce} attribute for each request.
71+
* </p>
72+
*
73+
* <p>
74+
* For example, if the configured {@code policyDirectives} is {@code script-src 'self'
75+
* 'nonce-{nonce}'}, and a
76+
* {@link org.springframework.security.web.header.NonceGeneratingFilter} has set the
77+
* {@code _csp_nonce} attribute to {@code "Nc3n83cnSAd3wc3Sasdfn9"}, then the written HTTP
78+
* header value would be {@code script-src 'self' 'nonce-Nc3n83cnSAd3wc3Sasdfn9'}.
79+
* </p>
80+
*
81+
* <p>
5882
* This implementation of {@link HeaderWriter} writes one of the following headers:
5983
* </p>
6084
* <ul>
@@ -79,20 +103,30 @@
79103
*
80104
* @author Joe Grandja
81105
* @author Ankur Pathak
106+
* @author Ziqin Wang
82107
* @since 4.1
108+
* @see org.springframework.security.web.header.NonceGeneratingFilter
83109
*/
84110
public final class ContentSecurityPolicyHeaderWriter implements HeaderWriter {
85111

86-
private static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy";
112+
public static final String CONTENT_SECURITY_POLICY_HEADER = "Content-Security-Policy";
87113

88-
private static final String CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only";
114+
public static final String CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only";
89115

90116
private static final String DEFAULT_SRC_SELF_POLICY = "default-src 'self'";
91117

118+
public static final String DEFAULT_NONCE_ATTRIBUTE_NAME = "_csp_nonce";
119+
120+
public static final String NONCE_PLACEHOLDER = "{nonce}";
121+
92122
private String policyDirectives;
93123

94124
private boolean reportOnly;
95125

126+
private boolean isNonceBased;
127+
128+
private String nonceAttributeName = DEFAULT_NONCE_ATTRIBUTE_NAME;
129+
96130
/**
97131
* Creates a new instance. Default value: default-src 'self'
98132
*/
@@ -120,18 +154,30 @@ public void writeHeaders(HttpServletRequest request, HttpServletResponse respons
120154
String headerName = (!this.reportOnly) ? CONTENT_SECURITY_POLICY_HEADER
121155
: CONTENT_SECURITY_POLICY_REPORT_ONLY_HEADER;
122156
if (!response.containsHeader(headerName)) {
123-
response.setHeader(headerName, this.policyDirectives);
157+
String csp;
158+
if (this.isNonceBased) {
159+
String nonce = (String) request.getAttribute(this.nonceAttributeName);
160+
Assert.state(nonce != null, "Nonce is unset");
161+
csp = this.policyDirectives.replace(NONCE_PLACEHOLDER, nonce);
162+
}
163+
else {
164+
csp = this.policyDirectives;
165+
}
166+
response.setHeader(headerName, csp);
124167
}
125168
}
126169

127170
/**
128-
* Sets the security policy directive(s) to be used in the response header.
171+
* Sets the security policy directive(s) to be used in the response header. The
172+
* {@code policyDirectives} may contain {@code {nonce}} as placeholders to be
173+
* replaced.
129174
* @param policyDirectives the security policy directive(s)
130175
* @throws IllegalArgumentException if policyDirectives is null or empty
131176
*/
132177
public void setPolicyDirectives(String policyDirectives) {
133178
Assert.hasLength(policyDirectives, "policyDirectives cannot be null or empty");
134179
this.policyDirectives = policyDirectives;
180+
this.isNonceBased = policyDirectives.contains(NONCE_PLACEHOLDER);
135181
}
136182

137183
/**
@@ -143,10 +189,44 @@ public void setReportOnly(boolean reportOnly) {
143189
this.reportOnly = reportOnly;
144190
}
145191

192+
/**
193+
* Sets the name of the servlet request attribute from which the nonce value is taken.
194+
* Defaults to {@code _csp_nonce} if unset.
195+
* @param nonceAttributeName the name of the nonce attribute
196+
* @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null} or
197+
* empty
198+
* @since 7.1
199+
*/
200+
public void setNonceAttributeName(String nonceAttributeName) {
201+
Assert.hasLength(nonceAttributeName, "nonceAttributeName cannot be null or empty");
202+
this.nonceAttributeName = nonceAttributeName;
203+
}
204+
205+
/**
206+
* Returns the name of the servlet request attribute from which the nonce value is
207+
* taken. Defaults to {@code _csp_nonce} if unset.
208+
* @return the name of the nonce attribute.
209+
* @since 7.1
210+
*/
211+
public String getNonceAttributeName() {
212+
return this.nonceAttributeName;
213+
}
214+
215+
/**
216+
* Returns whether the content security policy is nonce-based. The CSP is considered
217+
* nonce-based if the configured {@code policyDirectives} string contains a
218+
* {@code {nonce}} placeholder.
219+
* @return whether the content security policy is nonce-based
220+
* @since 7.1
221+
*/
222+
public boolean isNonceBased() {
223+
return this.isNonceBased;
224+
}
225+
146226
@Override
147227
public String toString() {
148228
return getClass().getName() + " [policyDirectives=" + this.policyDirectives + "; reportOnly=" + this.reportOnly
149-
+ "]";
229+
+ "; isNonceBased=" + this.isNonceBased + "; nonceAttributeName=" + this.nonceAttributeName + "]";
150230
}
151231

152232
}

web/src/main/java/org/springframework/security/web/server/header/ContentSecurityPolicyServerHttpHeadersWriter.java

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,44 @@
1616

1717
package org.springframework.security.web.server.header;
1818

19+
import java.util.List;
20+
1921
import org.jspecify.annotations.Nullable;
2022
import reactor.core.publisher.Mono;
2123

22-
import org.springframework.security.web.server.header.StaticServerHttpHeadersWriter.Builder;
24+
import org.springframework.http.HttpHeaders;
2325
import org.springframework.util.Assert;
2426
import org.springframework.web.server.ServerWebExchange;
2527

2628
/**
2729
* Writes the {@code Content-Security-Policy} response header with configured policy
2830
* directives.
2931
*
32+
* <p>
33+
* With related directives specified, web clients could block inline {@code <script>} or
34+
* {@code <style>} blocks in the HTML to mitigate XSS attacks injecting malicious inline
35+
* blocks. To allow intended inline blocks, a CSP directive (usually {@code script-src} or
36+
* {@code style-src}) may specify a hard-to-guess nonce matching the nonce attributes of
37+
* inline HTML blocks.
38+
*
39+
* <p>
40+
* To ease writing nonce-based CSP headers, this class replaces the {@code {nonce}}
41+
* placeholder in the {@code policyDirectives} with a real nonce value read from a
42+
* {@link ServerWebExchange#getAttribute(String) request attribute} named
43+
* {@code _csp_nonce} (or another configured attribute name). A
44+
* {@link org.springframework.security.web.server.header.NonceGeneratingWebFilter} can be
45+
* configured to generate a unique secure random {@code _csp_nonce} attribute for each
46+
* request.
47+
*
48+
* <p>
49+
* For example, if the configured {@code policyDirectives} is {@code script-src 'self'
50+
* 'nonce-{nonce}'}, and a
51+
* {@link org.springframework.security.web.server.header.NonceGeneratingWebFilter} has set
52+
* the {@code _csp_nonce} attribute to {@code "Nc3n83cnSAd3wc3Sasdfn9"}, then the written
53+
* HTTP header value would be {@code script-src 'self' 'nonce-Nc3n83cnSAd3wc3Sasdfn9'}.
54+
*
3055
* @author Vedran Pavic
56+
* @author Ziqin Wang
3157
* @since 5.1
3258
*/
3359
public final class ContentSecurityPolicyServerHttpHeadersWriter implements ServerHttpHeadersWriter {
@@ -36,26 +62,48 @@ public final class ContentSecurityPolicyServerHttpHeadersWriter implements Serve
3662

3763
public static final String CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only";
3864

65+
public static final String DEFAULT_NONCE_ATTRIBUTE_NAME = "_csp_nonce";
66+
67+
public static final String NONCE_PLACEHOLDER = "{nonce}";
68+
3969
private @Nullable String policyDirectives;
4070

4171
private boolean reportOnly;
4272

43-
private @Nullable ServerHttpHeadersWriter delegate;
73+
private String nonceAttributeName = DEFAULT_NONCE_ATTRIBUTE_NAME;
74+
75+
private boolean isNonceBased;
4476

4577
@Override
4678
public Mono<Void> writeHttpHeaders(ServerWebExchange exchange) {
47-
return (this.delegate != null) ? this.delegate.writeHttpHeaders(exchange) : Mono.empty();
79+
return Mono.justOrEmpty(this.policyDirectives).flatMap(csp -> {
80+
String headerName = resolveHeader(this.reportOnly);
81+
HttpHeaders headers = exchange.getResponse().getHeaders();
82+
if (!headers.containsHeader(headerName)) {
83+
if (this.isNonceBased) {
84+
String nonce = exchange.getAttribute(this.nonceAttributeName);
85+
if (nonce == null) {
86+
return Mono.error(new IllegalStateException("Nonce is unset"));
87+
}
88+
csp = csp.replace(NONCE_PLACEHOLDER, nonce);
89+
}
90+
headers.put(headerName, List.of(csp));
91+
}
92+
return Mono.empty();
93+
});
4894
}
4995

5096
/**
51-
* Set the policy directive(s) to be used in the response header.
97+
* Set the policy directive(s) to be used in the response header. The
98+
* {@code policyDirectives} may contain {@code {nonce}} as placeholders to be
99+
* replaced.
52100
* @param policyDirectives the policy directive(s)
53101
* @throws IllegalArgumentException if policyDirectives is {@code null} or empty
54102
*/
55103
public void setPolicyDirectives(String policyDirectives) {
56104
Assert.hasLength(policyDirectives, "policyDirectives must not be null or empty");
57105
this.policyDirectives = policyDirectives;
58-
this.delegate = createDelegate();
106+
this.isNonceBased = policyDirectives.contains(NONCE_PLACEHOLDER);
59107
}
60108

61109
/**
@@ -65,16 +113,42 @@ public void setPolicyDirectives(String policyDirectives) {
65113
*/
66114
public void setReportOnly(boolean reportOnly) {
67115
this.reportOnly = reportOnly;
68-
this.delegate = createDelegate();
69116
}
70117

71-
private @Nullable ServerHttpHeadersWriter createDelegate() {
72-
if (this.policyDirectives == null) {
73-
return null;
74-
}
75-
Builder builder = StaticServerHttpHeadersWriter.builder();
76-
builder.header(resolveHeader(this.reportOnly), this.policyDirectives);
77-
return builder.build();
118+
/**
119+
* Sets the name of the {@link ServerWebExchange#getAttribute(String) exchange
120+
* attribute} from which the nonce value is taken. Defaults to {@code _csp_nonce} if
121+
* unset.
122+
* @param nonceAttributeName the name of the nonce attribute
123+
* @throws IllegalArgumentException if {@code nonceAttributeName} is {@code null} or
124+
* empty
125+
* @since 7.1
126+
*/
127+
public void setNonceAttributeName(String nonceAttributeName) {
128+
Assert.hasLength(nonceAttributeName, "nonceAttributeName cannot be null or empty");
129+
this.nonceAttributeName = nonceAttributeName;
130+
}
131+
132+
/**
133+
* Returns the name of the {@link ServerWebExchange#getAttribute(String) request
134+
* attribute} from which the nonce value is taken. Defaults to {@code _csp_nonce} if
135+
* unset.
136+
* @return the name of the nonce attribute.
137+
* @since 7.1
138+
*/
139+
public String getNonceAttributeName() {
140+
return this.nonceAttributeName;
141+
}
142+
143+
/**
144+
* Returns whether the content security policy is nonce-based. The CSP is considered
145+
* nonce-based if the configured {@code policyDirectives} string contains a
146+
* {@code {nonce}} placeholder.
147+
* @return whether the content security policy is nonce-based
148+
* @since 7.1
149+
*/
150+
public boolean isNonceBased() {
151+
return this.isNonceBased;
78152
}
79153

80154
private static String resolveHeader(boolean reportOnly) {

0 commit comments

Comments
 (0)