A recent point release in Rails led to unexpected Javascript errors in one of our projects. These errors related to Content-Security-Policy violations, and they arose when the server returned a 304 (Not modified) response. In this article we investigate why these errors have arisen and how we fixed them.

Introduction

A Rails patch release (v6.1.5.1) was recently made available to address a XSS vulnerability in some contexts. Eager to keep up-to-date with security patches we proceeded to upgrade, test and deploy. Things looked OK for a time, but then we noticed that some clients were getting some quite unexpected browser errors. Specifically, these errors related a Content Security Policy (CSP) violation being triggered on some pages, e.g.

Browser errors report a violation of the Content-Security-Policy by inline scripts, even though these have been decorated with an appropriate nonce value.

After some additional investigation it appeared that the errors were thrown when the server returned a 304 (Not modified) response.

If you are unfamiliar with the Content-Security-Policy HTTP header (or if you need a quick refresher) I would encourage you to take a look at this earlier blog post which introduces the basic ideas.

In this case we are concerned with the script-src directive and the technique of using a nonce value in the CSP header to allowlist any inline scripts which are decorated with a matching nonce value. This nonce technique had been used across the site in question for some time, allow-listing various legacy inline scripts. However, after the Rails upgrade we noticed that these nonce-covered inline scripts were starting to throw errors.

Why is the browser reporting a CSP violation?

The problem arises when the URL in question has already been loaded in the browser. By virtue of a conditional-get, any subsequent requests for the URL from the browser will include an If-Not-Modified header. The server will recognise this header and will respond with a 304 response, if it deems that the resource has not changed.

This 304 is an empty-bodied response indicating that the resource has not changed on the server and instructing the browser to use it's own cached version. Such responses are an HTTP efficiency measure to avoid transferring a page over the network when the browser already holds an identical version.

So far so good. But the CSP header in our 304 response includes a new nonce value, which was being applied to the cached page. This had the effect of invalidating the existing inline scripts, which still had the original nonce value associated.

We can set up a quick demonstration of this effect in a fresh Rails app, you can browse the code or download this demo from the GitHub repo.

Why has 304 stale nonce problem just appeared?

Reviewing the changes that were pulled into this Rails patch, the most likely culprit seemed to be changes to the ActionDispatch::ContentSecurityPolicy::Middleware:

  
module ActionDispatch #:nodoc:
  class ContentSecurityPolicy
    class Middleware
      CONTENT_TYPE = "Content-Type"t
      POLICY = "Content-Security-Policy"
      POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only"

      def initialize(app)
        @app = app
      end

      def call(env)
        request = ActionDispatch::Request.new env
        _, headers, _ = response = @app.call(env)

        return response unless html_response?(headers)   # This line was removed
        return response if policy_present?(headers)

        if policy = request.content_security_policy
          nonce = request.content_security_policy_nonce
          nonce_directives = request.content_security_policy_nonce_directives
          context = request.controller_instance || request
          headers[header_name(request)] = policy.build(context, nonce, nonce_directives)
        end

        response
      end

      private
        def html_response?(headers)    # This supporting method was also removed
          if content_type = headers[CONTENT_TYPE]
            /html/.match?(content_type)
          end
        end
  …
   

As highlighted in the code above, the following line has been removed in the call method:

  
return response unless html_response?(headers)
   

This line was responsible for exiting the middleware early, and not setting a CSP header, in the event that the response was not an HTML page. In principle, the CSP header should only be required in the context of a webpage, however, discussion on the Rails GitHub issue suggests that there may be effective CSP workarounds that exploit non-HTTP requests. I did not research this aspect any further, but suffice to say that there may be sound reasons why we don't want to restrict our CSP header to HTML-only. In this context the change above makes sense, but what does this have to do with our 304 response?

It turns out that our empty-bodied 304 response does not include a `Content-Type` header. So the return response unless html_response?(headers) was firing for 304 responses, ensuring that any 304 response did not get embellished with a CSP header. No CSP header, no browser violations, no problem. So what is the correct behaviour?

Based on the discussion on this issue on webappsec-csp repo, it seems that the CSP header on a 304 response will overwrite the existing CSP header on the cache version. This is the expected behaviour. Thus if your 304 response includes a CSP header with a new nonce, it's going to clobber your existing header, and render all the inline nonce attributes on your cached page as invalid.

What's the solution? Don't pass a Content-Security-Policy header in your 304 response.

Ensure 304 response excludes CSP header

A sensible solution seems to be the removal of the CSP header when the server is responding with a 304. After spending some time attempting to influence the CSP from the controller layer, I eventually concluded that the best approach would be to write a simple middleware to do the job, after all Rails is using a middleware to set the CSP header, as we have seen.

In the interest of a clearn separation, and rather than tinkering with the logic of the existing CSP middleware, I opted to write a separate middleware for the task of removing the CSP in the case of a 304 response. We will create a file at lib/middlewares/remove_unneeded_content_security_policy.rb that looks like this:

  
class RemoveUnneededContentSecurityPolicy
  POLICY_KEY = "Content-Security-Policy"
  CONTENT_TYPE_KEY = "Content-Type"

  def initialize(app)
   @app = app
  end

  def call(env)
    status, headers, response = @app.call(env)
    if status==304
      headers.delete(POLICY_KEY)
    end
    [status, headers, response]
  end
end 
   

We plug this into our application middleware stack in config/application.rb:

  
    require_relative '../lib/middlewares/remove_unneeded_content_security_policy'
    …
    config.middleware.insert_before(ActionDispatch::ContentSecurityPolicy::Middleware, RemoveUnneededContentSecurityPolicy)
   

With these changes, any 304 response from our Rails server will omit the Content-Security-Policy header.

Final note

It should be noted that the suggested default nonce generator in Rails 7 has recently changed in config/initializers/content_security_policy.rb. The effective change is as follows:

  
content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }    # Previous suggestion in CSP initializer file
content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }    # New suggestion in CSP initializer file
   

The reason for this change can be seen in the pull request, but it seems to be motivated by the same problem we have discussed above, where a conditional get triggers a 304 response with a new nonce in the CSP header. The solution proposed here is to tie the nonce value to your session ID, so that it doesn't vary. This updated nonce-generator would prevent the problem, but it would obviously be more secure to generate the nonce on each request, so in our case dropping the CSP for 304 response seems like the right thing to do.

References

  1. Rails v6.1.5.1 tag
  2. The GitHub compare to see the changes in the 6.1.5.1 patch
  3. The individual commit which is leads to CSP header on our 304 responses
  4. GitHub repo with demo project
  5. WebAppSec issue discussing correct behaviour for CSP header in a 304 response
  6. Thoughtbot article on the conditional-get in Rails

Comments

There are no existing comments

Got your own view or feedback? Share it with us below …

×

Subscribe

Join our mailing list to hear when new content is published to the VectorLogic blog.
We promise not to spam you, and you can unsubscribe at any time.