Skip to content

Support nonce-based Content Security Policy - #18499

Merged
jzheaux merged 9 commits into
spring-projects:mainfrom
ziqin:gh-10826
Oct 14, 2026
Merged

jzheaux merged 9 commits into
spring-projects:mainfrom
ziqin:gh-10826

Conversation

@ziqin

@ziqin ziqin commented Jan 14, 2026 •

Copy link
Copy Markdown
Contributor

This PR implements gh-10826.

Introduction

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 PR introduces support for dynamically generating a secure random nonce for CSP.

Implementation

A nonce is written to the CSP header in 2 steps:

  1. NonceGeneratingFilter / NonceGeneratingWebFilter generates a nonce and set it as a request attribute named _csp_nonce;
  2. ContentSecurityPolicyHeaderWriter / ContentSecurityPolicyServerHttpHeadersWriter reads the _csp_nonce attribute and write it to the CSP header, replacing the {nonce} placeholder in the configured policyDirectives.

Note:

  • The whole process is separated in 2 steps because by default a header writer cannot set a request attribute visible to views 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.

Configuration

This PR also adds configurers for setting up nonce-based CSP with Java/Kotlin lambda DSL, including the ability to specify a request matcher to determine whether a request requires CSP protection.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .headers((headers) -> headers
            .contentSecurityPolicy((csp) -> csp
                .policyDirectives("default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'nonce-{nonce}'")
                .nonceAttributeName("_csp_nonce")
                .requireCspMatchers("/admin/**", "/user/**")
            )
        );
    return http.build();
}

The ability to enable CSP conditionally is useful especially when the CSP directives contain a nonce, because the HTTP header value becomes dynamic and may change the cacheability of static asserts protected by Spring Security.

The conditional enabling of CSP protection is implemented in spring-security-config by wrapping the ContentSecurityPolicyHeaderWriter / ContentSecurityPolicyServerHttpHeadersWriter with the existing DelegatingRequestMatcherHeaderWriter / ServerWebExchangeDelegatingServerHttpHeadersWriter. The _csp_nonce attribute is generated unconditionally if nonce-based CSP is configured.

Breaking changes in corner case

In commit 381dc38 I changed the return type of 2 public configuration APIs:

  • ContentSecurityPolicySpec#reportOnly(boolean)
  • ContentSecurityPolicySpec#policyDirectives(String)

The return type is changed from HeaderSpec to ContentSecurityPolicySpec to allow method chaining.

I believe this API change was missed during the migration to lambda DSL and think it's small enough to be updated when releasing v7.1, but if 100% API stability is required, we could simply revert that commit, or introduce new APIs and deprecate the old ones.

@ziqin

ziqin commented Mar 17, 2026

Copy link
Copy Markdown
Contributor Author

It seems that #18840 may affect the two-step assumption made here.

If HTTP headers are written eagerly, HeaderWriter#writeHeaders(HttpServletRequest, HttpServletResponse) may be changed to run earlier, which means it may be possible to set a nonce attribute directly in ContentSecurityPolicyHeaderWriter without requiring an extra NonceGeneratingFilter.

@jzheaux jzheaux self-assigned this Mar 27, 2026
@jzheaux jzheaux added in: web An issue in web modules (web, webmvc) type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged labels Mar 27, 2026
@ziqin
ziqin force-pushed the gh-10826 branch 2 times, most recently from 00533a5 to 0ecdc80 Compare March 29, 2026 03:36

@jzheaux jzheaux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for contributing this feature, @ziqin! I've left some feedback inline. Mostly, I focused on the reactive stack; please consider applying my observations to both stacks.

@ziqin

ziqin commented Apr 4, 2026 •

Copy link
Copy Markdown
Contributor Author

@jzheaux Thank you for your detailed and insightful review feedback!

I have resolved most problems you pointed out, but there are still some points that I am uncertain about (see my comments for details). Would you like to help me about these?

@ziqin
ziqin requested a review from jzheaux April 4, 2026 17:59

@jzheaux jzheaux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @ziqin, for this thorough PR!

In addition to my inline notes, will you please take a look at XML support? We can either:

  • Add the nonce-generating filter there too so that the XML support continues to work, or
  • It will need an error message stating that nonces aren't supported in XML config

Either way, no need to add nonce-attribute-name, etc. This is to make sure that this change doesn't inadvertently break XML config.

Additionally, for brevity, I made some comments in servlet that naturally also apply to reactive. If you are unsure, please feel free to ask!

@jzheaux jzheaux added this to the 7.2.x milestone Sep 2, 2026
@ziqin

ziqin commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor Author

@jzheaux

Thanks again for your patient review.

Following your guidance, I have resolved most problems. Please see my replies to inline comments for details and my remaining questions.

  • By introducing a request matcher to ContentSecurityPolicyHeaderWriter instead of using DelegatingRequestMatcherHeaderWriter, I managed to change several places in HeadersConfigurer and ServerHttpSecurity back to their original state before this PR.

  • The request/exchange attribute for the nonce object is renamed to _csp so that the nonce value could be referenced in a template as ${_csp.nonce}.

  • XML namespace configuration support is added in 55d55a9 to allow {nonce} placeholders in CSP directives. I used CompositeFilter there for ease of implementation, and consequently there will be structural differences between the XML-built filter chain and that built with Java lambda DSL. I hope they provide the same functionalities.

@ziqin
ziqin requested a review from jzheaux September 10, 2026 22:09
@jzheaux

jzheaux commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks again for all the work on this, @ziqin, and for your patience through the review rounds. To help get this over the line before I'm away next week, I've pushed a handful of polish commits to the branch. They're intentionally separate from yours so you can see exactly what changed, and please push back as needed; each can be dropped or reworked independently.

What I changed and why:

  • Expose ContentSecurityPolicyNonce instead of Mono (3965c89): While building a WebFlux + Thymeleaf sample I found that ${_csp.nonce} failed in reactive templates since the attribute was a Mono. This is the template-parity concern you raised earlier, thank you. The reactive filter now publishes the same lazy, memoized ContentSecurityPolicyNonce that the servlet one does, so ${_csp.nonce} works identically in both stacks. The writer still reads a Mono from the internal attribute, so a reactive nonce source can be added later without changing the public attribute.
  • Ensure RequestMatcher does not leak across invocations (0b9971f): A second contentSecurityPolicy(...) call now replaces everything the first one configured (servlet: a fresh ContentSecurityPolicyConfig; reactive: the matcher is reset the way the directives already were). That keeps the new settings on the existing lifecycle. As discussed earlier, making repeated calls compose is worthwhile, and I've added Repeated calls to headers.contentSecurityPolicy(...) should not clobber previous ones #19842 to track that.
  • Do not support CSP nonces in XML (458918a): This one reverses work you did at my request, and I'm sorry for the churn. The XML namespace isn't receiving new features, so rather than grow it, a {nonce} in <content-security-policy> now fails at parse time with a message pointing to Java configuration.
  • Wait to expose StringKeyGenerator (5c9fc06): The generator constructors on the two filters are package-private for now. They're easy to make public later, and there's no way through the configurers to supply one yet.
  • Polish (6f8544f): Drop an unneeded @SuppressWarnings, make the missing-nonce errors name the filter to register, add nullability to the reactive filter field, and rename leftover Kotlin DSL fields.
  • Rethrow first exception (8da4529): Your CompositeHeaderWriter change was the right idea. It now rethrows the first exception (suppressing later ones) so that the exception type is preserved. I believe this also answers your question about the exception type.

I'll resolve the review threads that these commits (and yours) have settled. I'd like to merge on Friday so it lands before I'm out. If you'd like any of the above reverted, or want more time, just say so and I'll wait. I'll add the reference documentation as a follow-on polish.

I've also filed a few follow-ups separately, and you're welcome to pick any of them up:

If I don't hear from you on any of those in the next few days, I'll add the status: ideal-for-contribution label to offer them to the rest of the community.

@jzheaux

jzheaux commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Regarding your comment about #18840:

Even though #18840 changes the header eagerness default, folks can still change to be lazy if they need that. Because of that, we need to support lazy headers regardless of #18840.

@jzheaux jzheaux modified the milestones: 7.2.x, 7.2.0-RC1 Oct 7, 2026
@ziqin

ziqin commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Josh: While building a WebFlux + Thymeleaf sample I found that ${_csp.nonce} failed in reactive templates since the attribute was a Mono.

I built an WebFlux + Thymeleaf sample and also confirmed that a Mono exchange attribute cannot be accessed directly in a template. 3965c89 is an important fix.

Spring WebFlux document mentions support for reactive model attribute:

Spring WebFlux, unlike Spring MVC, explicitly supports reactive types in the model (for example, Mono<Account> or io.reactivex.Single<Account>). Such asynchronous model attributes can be transparently resolved (and the model updated) to their actual values at the time of @RequestMapping invocation, provided a @ModelAttribute argument is declared without a wrapper, as the following example shows...

But exchange attribute is not covered.

I am sorry for saying “Now both Spring MVC and WebFlux applications can render the nonce with a template like <style th:nonce="${_csp.nonce}">” without testing on a running example in #18499 (comment).

.requestMatchers("/foo/**"))
.contentSecurityPolicy((csp) -> csp
.policyDirectives("default-src 'self'")
.requestMatchers("/bar/**")));

@ziqin ziqin Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If gh-19842 is supported, the configureWhenContentSecurityPolicyConfiguredTwiceThenSecondCallReplacesFirst test will fail because ContentSecurityPolicyConfig#requestMatchers(String...) rejects the second call.

This behavior is introduced to (and documented in) requestMatcher(RequestMatcher) and requestMatchers(String...) to prevent a previous requestMatchers(String...) call from being accidentally overridden by a consecutive requestMatcher(RequestMatcher) call (or vice versa) in the same contentSecurityPolicy(Customizer) call.

@ziqin ziqin Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it is fine to omit the first requestMatchers("/foo/**") here for the purpose of the test, so that we don't commit to any specific behavior through the tests (neither replacing everything nor retaining existing configurations).

@Configuration
@EnableWebSecurity
static class ContentSecurityPolicyConfiguredTwiceConfig {

	@Bean
	SecurityFilterChain filterChain(HttpSecurity http) {
		// @formatter:off
		http
			.headers((headers) -> headers
				.defaultsDisabled()
				.contentSecurityPolicy((csp) -> csp
					.policyDirectives("script-src 'self'"))
				.contentSecurityPolicy((csp) -> csp
					.policyDirectives("default-src 'self'")
					.requestMatchers("/bar/**")));
		// @formatter:on
		return http.build();
	}

}

For reactive stack:

@Test
public void headersWhenContentSecurityPolicyConfiguredTwiceThenSecondCallReplacesFirst() {
	String headerName = ContentSecurityPolicyServerHttpHeadersWriter.CONTENT_SECURITY_POLICY;
	String policyDirectives = "default-src 'self'";
	// @formatter:off
	this.http.headers((headers) -> headers
		.contentSecurityPolicy((csp) -> csp
			.policyDirectives("script-src 'self'"))
		.contentSecurityPolicy((csp) -> csp
			.exchangeMatchers("/bar/**")
			.policyDirectives(policyDirectives)));
	// @formatter:on
	WebTestClient client = WebTestClientBuilder.bindToWebFilters(this.http.build()).build();
	// @formatter:off
	client.get()
		.uri("https://example.com/bar")
		.exchange()
		.expectHeader().valueEquals(headerName, policyDirectives);
	client.get()
		.uri("https://example.com/foo")
		.exchange()
		.expectHeader().doesNotExist(headerName);
	// @formatter:on
}

@ziqin ziqin Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added the above changes to ziqin@1e81fd0

@jzheaux jzheaux Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for noticing this and for thinking ahead! Actually, I think we should address this in another way and I've added 64ee4eb just now.

While we do want the DSL to be able to compose at the configurer level, it conventionally allows individual fields to override previous calls.

ziqin and others added 8 commits October 14, 2026 11:42
When a strict Content Security Policy is used, browsers block inline
<script> and <style> blocks to mitigate XSS. To allow intended inline
blocks, applications can generate a hard-to-guess nonce and include it
in both the policy and the allowed inline blocks.

This commit adds ContentSecurityPolicyNonceGeneratingFilter and
ContentSecurityPolicyNonceGeneratingWebFilter, which lazily generate a
nonce and expose it as a request attribute for views to render. The
CSP header writers replace the {nonce} placeholder in the configured
directives with that nonce.

It also adds Java and Kotlin DSL support for configuring nonce-based
CSP, including a request matcher for deciding which requests require
CSP, and changes the composite header writers to avoid failing fast.

Closes spring-projectsgh-10826

Signed-off-by: Ziqin Wang <[email protected]>
Since the nonce-generating filter does not yet support reactive sources,
we complicate the request attribute value by placing a Mono inside of it.

In the future, if we do support reactive sources, folks will set their
own attribute once they resolve the Mono in something like a ControllerAdvice

Signed-off-by: Josh Cummings <[email protected]>
Upon further review, I don't think that we should support this at
this time. XML is effectively deprecated and we aren't adding more
features unless it comes for free.

Signed-off-by: Josh Cummings <[email protected]>
I'm not yet convinced that this is the right interface to expose for
nonce configuration. Also, it's just as easy to publish a new filter
than configure this one, so this commit leaves it out for now.

Signed-off-by: Josh Cummings <[email protected]>
Since the most common case is that there will be just one
exception, this commit propagates that one, suppressing any
additional

Signed-off-by: Josh Cummings <[email protected]>
ContentSecurityPolicySpec(String) has no callers, and it would not
have applied the defaults that the no-arg constructor now sets up, like
the nonce filter and the exchange matcher.

Thanks to Ziqin Wang for pointing this out in review.

Signed-off-by: Josh Cummings <[email protected]>
Consistent with other DSL methods like HstsConfig#requestMatcher,
the last call to requestMatcher(s) or exchangeMatcher(s) now wins
instead of failing.

Signed-off-by: Josh Cummings <[email protected]>
@jzheaux
jzheaux enabled auto-merge (rebase) October 14, 2026 18:40
@jzheaux
jzheaux merged commit 3c8e22c into spring-projects:main Oct 14, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

in: web An issue in web modules (web, webmvc) type: enhancement A general enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants