Spring Security错误页Content-Security-Policy nonce不匹配问题
普通Thymeleaf模板页面中可正常使用Content-Security-Policy nonce机制,但在404等自定义错误页(同样基于Thymeleaf模板生成)中,作为模型属性传入页面的nonce值与Content-Security-Policy HTTP响应头中指定的nonce值不匹配,触发内容安全策略违规,导致自定义错误页出现脚本加载错误。Chrome控制台报错信息如下:
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”).
Spring Security CSP基础配置
@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(); }
自定义CSP nonce生成过滤器
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)); } }
Nonce响应包装类
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)); } }
全局Nonce注入逻辑
nonce值通过ControllerAdvice全局注入页面模型,代码如下:
@ControllerAdvice public class ContentSecurityPolicyControllerAdvice { @ModelAttribute public void addAttributes(Model model, HttpServletRequest request) { model.addAttribute("nonce", request.getAttribute("cspNonce")); } }
正常运行的首页与异常的错误页在Thymeleaf、HTML层面遵循完全相同的编写规则。
正常首页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>
404错误页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>
加载无效URL触发404时的应用调试输出如下:
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='
本次运行中,插入到CSP响应头的nonce值为qPhdJiUAAkKHrwQBvxzxUz0OUUU4UXaxLcDErhl4g7U=,但页面从视图模型获取的nonce值为OiZmhtGlYMgb4X+pcFIwM41GzEkre3YvfkLCHFqoqIU=,二者不一致。
已提供可通过./gradlew bootRun直接运行的最小复现代码,供排查参考,寻求该问题的根因与解决方案。
内容的提问来源于stack exchange,提问作者user19281570

