We use a Content-Security-Policy
nonce successfully on normal pages (Thymeleaf templates). But when we try to do the same on error pages (e.g. 404), the nonce received as a model attribute does not match the nonce specified in the Content-Security-Policy
HTTP header. This mismatch causes a policy violation and therefore script errors in our custom error page (also generated from a Thymeleaf template). In the Chrome console, the errors are
Content Security Policy: The page’s settings blocked the loading of a resource at http://localhost:8080/webjars/jquery/3.6.0/jquery.min.js (“script-src”).
Content Security Policy: The page’s settings blocked the loading of a resource at inline (“script-src”).
We enable the policy in the Spring Security configuration:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return
http.headers()
.contentSecurityPolicy("script-src 'strict-dynamic' 'nonce-{nonce}'")
.and().and()
.addFilterBefore(new ContentSecurityPolicyNonceFilter(), HeaderWriterFilter.class)
.build();
}
The filter is:
public class ContentSecurityPolicyNonceFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
var nonceArray = new byte[32];
(new SecureRandom()).nextBytes(nonceArray);
var nonce = Base64Utils.encodeToString(nonceArray);
request.setAttribute("cspNonce", nonce);
chain.doFilter(request, new NonceResponseWrapper((HttpServletResponse) response, nonce));
}
}
NonceResponseWrapper
is
class NonceResponseWrapper extends HttpServletResponseWrapper {
private final String nonce;
NonceResponseWrapper(HttpServletResponse response, String nonce) {
super(response);
this.nonce = nonce;
}
private String getHeaderValue(String name, String value) {
final String retVal;
if (name.equals("Content-Security-Policy") && StringUtils.hasText(value)) {
retVal = value.replace("{nonce}", nonce);
} else {
retVal = value;
}
return retVal;
}
@Override
public void setHeader(String name, String value) {
super.setHeader(name, getHeaderValue(name, value));
}
@Override
public void addHeader(String name, String value) {
super.addHeader(name, getHeaderValue(name, value));
}
}
The nonce value is provided to the page via ControllerAdvice
:
@ControllerAdvice
public class ContentSecurityPolicyControllerAdvice {
@ModelAttribute
public void addAttributes(Model model, HttpServletRequest request) {
model.addAttribute("nonce", request.getAttribute("cspNonce"));
}
}
The working index page and the dysfunctional error page follow the same pattern in Thymeleaf and HTML terms:
index.html:
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<title>Application</title>
<script th:src="@{/webjars/jquery/3.6.0/jquery.min.js}" th:nonce="${nonce}"></script>
<script th:inline="javascript" th:nonce="${nonce}">
const randomNumber = /*[[${randomNumber}]]*/ -1;
$(function() {
$('#a-number').text(randomNumber);
});
</script>
</head>
<body>
<h1>Welcome</h1>
<p>Your random number is <span id="a-number">unknown</span>.</p>
</body>
</html>
error/404.html:
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<title>404 Error</title>
<script th:src="@{/webjars/jquery/3.6.0/jquery.min.js}" th:nonce="${nonce}"></script>
<script th:nonce="${nonce}">
$(function() {
const timestampString = new Date().toISOString();
$('#timestamp').text(timestampString);
});
</script>
</head>
<body>
<h1>404 - Page Not Found</h1>
<p>The current time is <span id="timestamp">unknown</span>.</p>
</body>
</html>
Application debug output when loading the invalid URL shows
Nonce for request = qPhdJiUAAkKHrwQBvxzxUz0OUUU4UXaxLcDErhl4g7U=
Content-Security-Policy = script-src 'strict-dynamic' 'nonce-qPhdJiUAAkKHrwQBvxzxUz0OUUU4UXaxLcDErhl4g7U='
Nonce for request = OiZmhtGlYMgb4X+pcFIwM41GzEkre3YvfkLCHFqoqIU=
Nonce for view model = OiZmhtGlYMgb4X+pcFIwM41GzEkre3YvfkLCHFqoqIU=
Nonce for request = sCbXWXA0TPjw+I/dui2bmee1vKKXG1Y2Xt3G7JkuZ04=
Content-Security-Policy = script-src 'strict-dynamic' 'nonce-sCbXWXA0TPjw+I/dui2bmee1vKKXG1Y2Xt3G7JkuZ04='
Nonce for request = hsGwh4+5oqg0W51zNprrT41rHnEeJRdHHO8KTMCSwL8=
Content-Security-Policy = script-src 'strict-dynamic' 'nonce-hsGwh4+5oqg0W51zNprrT41rHnEeJRdHHO8KTMCSwL8='
In this run, the nonce interpolated into the policy is qPhdJiUAAkKHrwQBvxzxUz0OUUU4UXaxLcDErhl4g7U=
, while the page is getting OiZmhtGlYMgb4X+pcFIwM41GzEkre3YvfkLCHFqoqIU=
from the view model.
I've constructed a minimal, runnable (./gradlew bootRun
) code base for this problem at https://gitlab.com/russell-medisens/nonce-problem.git for anyone who might take a look.