0

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.

  • 404 on that gitlab link but I would love to see that code; is it available anywhere? – nasch Dec 15 '22 at 20:16
  • @nasch I have re-opened the repository. The initial commit on the master branch has the problem as described in the README.md. The last commit has the solution described in [my answer](https://stackoverflow.com/a/72558264/19281570). – user19281570 Dec 19 '22 at 15:17
  • Thank you very much; I have since figured out my issue and I think it's something different than what you encountered. Hopefully the code will be helpful to someone. – nasch Dec 19 '22 at 18:58
  • @user19281570 In my case if I am using th:nonce="${nonce}" it is not populating value and even if I am doing nonce = "qPhdJiUAAkKHrwQBvxzxUz0OUUU4UXaxLcDErhl4g7U=" in script tag it is not showing the value it is just showing(). Can you point me why it is not populating the value? Seems like may be some version issue that why script tag is not supporting nonce keyword but not sure. – mksmanjit Feb 09 '23 at 05:29
  • @user19281570 Even I ran your shared gitlab code and I did not see nonce value populated in browser see below screen shot. https://i.postimg.cc/Qtw9m6Xw/nonce.jpg – mksmanjit Feb 09 '23 at 06:05

1 Answers1

0

I believe I've solved this problem by changing the filter to avoid overwriting an existing nonce:

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        final String nonce;
        final Object existingNonce = request.getAttribute(REQUEST_NONCE_ATTRIBUTE);

        if (existingNonce == null) {
            final var nonceArray = new byte[NONCE_SIZE];
            SECURE_RANDOM.nextBytes(nonceArray);
            nonce = Base64Utils.encodeToString(nonceArray);
            request.setAttribute(REQUEST_NONCE_ATTRIBUTE, nonce);
            System.err.format("Nonce generated in filter = %s%n", nonce);
        } else {
            nonce = (String) existingNonce;
            System.err.format("Existing nonce retained in filter = %s%n", nonce);
        }

        chain.doFilter(request, new NonceResponseWrapper((HttpServletResponse) response, nonce));
    }

My understanding is that when a requested page is not found, Spring performs a forward (rather than a redirect), but the filter is invoked a second time in the process of serving the substituted 404 page. This code change preserves any existing nonce so that it can be provided to the view model for the error page.