Every Rails response has a default ETag
header. In theory this would
enable caching
Show archive.org snapshot
for multiple requests to the same resource. Unfortunately the default ETags produced by Rails are
effectively random
Show archive.org snapshot
, meaning they can never match a future request.
Understanding ETags
When your Rails app responds with ETag
headers, future requests to the same URL can be answered with an empty response if the underlying content hasn't changed. This saves CPU time and reduces the bandwidth cost for a request/response exchange to about 1 KB.
The most efficient way to produce and compare ETags are the
fresh_when
and stale?
helpers
Show archive.org snapshot
. You can use them in your controllers to compare previous ETags to your current data, and quickly send an empty 304 Not Modified
response if the data hasn't changed.
The problem with default ETags in Rails
When your controllers don't explicitly set an ETag, Rails will still send a default ETag Show archive.org snapshot by hashing the response body. While this still requires the server to re-render the view for unchanged content, you don't need to send unchanged HTML over the network.
Tip
With the default ETag you don't need to care what goes into an ETag.
When you find it hard to produce a correct ETag for a complex view, you may find it easier to make the view fast (using fragment caching Show archive.org snapshot ) and just rely on the default ETag.
Unfortunately, since most Rails application layouts insert randomly rotating CSRF tokens and CSP nonces into the HTML, two requests for the same data state will never produce the same response bytes:
<html>
<head>
<!-- 👇 This global CSP token is random for every request -->
<meta name="csrf-token" content="zhpujoWj6igDbw8" />
<!-- 👇 This CSP nonce is random for every request -->
<meta name="csp-nonce" content="vZjHiccwzNBcqUA" />
...
</head>
<body>
...
<form ...>
<!-- 👇 This per-form CSRF token is random for every request -->
<input type="hidden" name="authenticity_token" value="TLfFFABlzZSWEpMi0J96j">
...
</form>
</body>
</html>
This means responses to the same resource will never send the same ETag twice, causing responses to never hit a cache Show archive.org snapshot .
Making ETags match again
Two requests to the same URL should produce the same ETag
for the same user and data state. Below you can find instructions how to remove redundant randomness from your tokens so your ETags can again match.
Before I make this change I like to add a test that ensures I did it correctly. For this add the following code to spec/requests/default_etag_spec.rb
. You may need to adjust the let :route_with_tokens
so it returns a good route within your app.
describe 'Default ETag' do
let :route_with_tokens do
# This route should render a full layout and a form so it has a CSP nonce and CSRF tokens.
# The controller action should rely on default ETags by Rack::ETag.
# It should not explicitly set an ETag using the `fresh_when` or `stale?` helpers.
new_user_session_path
end
before do
# Don't touch the asset pipeline or this spec will take 30 seconds to compile assets that we don't need
allow_any_instance_of(ActionView::Base).to receive(:javascript_include_tag).and_return('script')
allow_any_instance_of(ActionView::Base).to receive(:stylesheet_link_tag).and_return('style')
allow_any_instance_of(ActionView::Base).to receive(:image_path).and_return('image')
# CSRF protection is disabled in tests by default. Activate it for this spec as
# randomly masked CSRF tokens are a major reason for never-matching ETags.
allow_any_instance_of(ActionController::Base).to receive(:allow_forgery_protection).and_return(true)
end
def get_route_with_tokens
get route_with_tokens
# Ensure that we render a global CSRF token
expect(response.body).to include('csrf-token')
# Ensure that we render a form CSRF token
expect(response.body).to include('authenticity_token')
# Ensure that we render a CSP nonce
expect(response.body).to include('csp-nonce')
end
def get_etag
etag = response.headers['E-Tag']
expect(etag).to be_present
etag
end
def reset_session
cookies.to_hash.keys.each {|key| cookies.delete(key) }
end
it 'sends the same ETag for two responses to the same route' do
get_route_with_tokens
first_etag = get_etag
get_route_with_tokens
second_etag = get_etag
expect(second_etag).to eq(first_etag)
end
it 'sends different ETags for two responses to the same route, but with different sessions' do
get_route_with_tokens
first_etag = get_etag
# Ensure that we do get a different ETag for a different user session
reset_session
get_route_with_tokens
second_etag = get_etag
expect(second_etag).to_not eq(first_etag)
end
end
Run the test to ensure it works within your app. The test should fail:
bundle exec rspec spec/requests/default_etag_spec.rb
Fixing random CSP nonces
Open config/initializers/content_security_policy.rb
and change the content_security_policy_nonce_generator
so it always returns the session ID instead of a random string:
Rails.application.config.content_security_policy_nonce_generator = lambda { |request|
# Force the session to exist or session.id will be nil
request.session.send(:load_for_write!) if request.session.id.blank?
request.session.id.to_s
}
Is this change secure?
While terminologically our CSP nonce is no longer a "nonce", this change can be considered secure for applications delivered using HTTPS and HSTS.
Using the session ID as a nonce is also mentioned as an option in the Rails security guide Show archive.org snapshot . It has this comment about security aspects:
This generation method is compatible with ETags, however its security depends on the session id being sufficiently random and not being exposed in insecure cookies.
Session IDs are generated using SecureRandom
, which is sufficiently random. Also since all communication to our app is protected through HTTPS and HSTS, our cookies are secure.
Fixing random CSRF tokens
A typical Rails application layout will have two CSRF tokens:
- A
<meta name="csrf-token">
in the<head>
. This is required so client-side JavaScripts may makePOST
,PATCH
,DELETE
requests. - An
<input type="hidden" name="authenticity_token">
within each form. Since Rails 5 this token is also different for each form Show archive.org snapshot .
Rails applies a random bitmask over your CSRF tokens, which we are going to remove.
In your ApplicationController
(and other base classes), make the following change:
class ApplicationController < ActionController::Base
protected
def form_authenticity_token(*)
encode_csrf_token(real_csrf_token(session))
end
end
If you have an older Rails version without encode_csrf_token
you can do this instead:
def form_authenticity_token(_form_options: {})
Base64.strict_encode64(real_csrf_token(session))
end
Is this change secure?
This change can be considered secure.
Random masking of CSRF tokens was introduced to mitigate BREACH Show archive.org snapshot , a side-channel attack against HTTPS that was published in 2013.
BREACH is pretty hard to exploit, especially in 2023:
- The attacker needs to force the victim to make about 1000 requests.
- The attacker needs to be within the same local network as the victim, so the attacker observe the victim's network traffic.
- With
SameSite
cookies being the default in all modern browsers, it is no longer impossible to force victim requests through IFRAMEs on a third-party site. In the context of an IFRAME the victim has a different session in your app. This produces different CSRF tokens (and CSP nonces).
Our change also disables per-form CSRF tokens Show archive.org snapshot . This cannot be abused unless you already have script injection somewhere else, which is game over anyway.
Checking successful integration
The test we added earlier should now pass:
bundle exec rspec spec/requests/default_etag_spec.rb
To manually test ETags, open the Network
tab of your DevTools. Then load the same URL twice and inspect both responses. They should show the same ETag
response header:
Network infrastructure may mutate your ETag
I recommend to deploy your app to a server and see if you get the same ETag for two responses to the same URL. Network infrastructure may modify the ETag
sent by your app.
In particular Apache's mod_deflate
appends a suffix -gzip
or -br
to your ETags. These ETags will never match what your application sees. You can disable this behavior through this directive:
DeflateAlterETag NoChange