Rails: Fixing ETags that never match

Updated . Posted . Visible to the public. Repeats.

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 make POST, 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:

Image

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
Henning Koch
Last edit
Henning Koch
Attachments
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2023-06-07 10:33)