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.
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
- Rails v6.1.5.1 tag
- The GitHub compare to see the changes in the 6.1.5.1 patch
- The individual commit which is leads to CSP header on our
304
responses - GitHub repo with demo project
- WebAppSec issue discussing correct behaviour for CSP header in a
304
response - 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 …