Caching in Rails < 6.1 may down parts of your application when using public cache control

Updated . Posted . Visible to the public. Repeats.

Proxy caching is a good feature to serve your publicly visible application content faster and reduce load on your servers. It is e.g. available in nginx, but also affects proxies delivered by ISPs.

Unfortunately, there is a little problem in Rails < 6.1 when delivering responses for different MIME-types. Say you have an arbitrary route in your Rails application that is able to respond with regular HTML and JSON. By sending the specific MIME type in the Accept header, you tell the application to either return HTML (text/html) or JSON (text/json). The problem is that Rails caches the response independently from the specified Accept header. This means that the first request made to the route is cached, regardless of the desired response format. While this works as long as only one type is requested, it will cause problems the moment a user requests a different MIME type.

That is of course a problem once your user caches (due to Cache-Control: private and local caching enabled), but it would probably not appear too often as it's unlikely that the user will request two different response formats from an action consecutively.

There is also an interesting article Show archive.org snapshot with thoughts on why content negotiation has many problems and why it should not be used too much in applications.

Attack scenario with public cache control

Be careful when using Cache-Control: public in your application!

Things turn out differently here. An attacker may perform a cache poisoning attack by sending a request with Accept: text/json to the homepage route of your server that may only respond with HTML. It will return a JSON string {"error":404,"message":"Not found"}. When the cache is controlled publicly, this response is now cached by the server or any other intermediate proxies for this requested URL.

Any other user that tries to access the homepage in their browser (thus, requesting text/html) will now see the blank page with this error text instead of the HTML content. Depending on how long the caching is performed, your page is now effectively down for a specific amount of time and/or for a specific amount of people that e.g. use the same ISP.

Make caches aware of MIME-types

This problem is fixed by the Vary-response header. You may pass a list of comma-separated header names as argument that will basically tell the caching logic (that deals with the response) to care about differences in these headers whenever a request comes in and to cache dependent on the concrete value of these headers.

By setting Vary: Accept we tell the server to store the current Accept-header of the request in the cache key for the according response. As soon as a different Accept header (let's say text/json) is sent in the next request, the server notices this difference and does not serve the already cached response, but will rather cache the new one that is returned for text/json, effectively resulting in two separate cache entries. This way you will profit from caching, but don't need to worry about wrong data being sent to the user.

Vary: Accept in Rails

Vary: Accept is unfortunately not included by default in Rails < 6.1. That is a bug Show archive.org snapshot and differs from the HTTP-specification in RFC 2616 14.44 which states:

An HTTP/1.1 server SHOULD include a Vary header field with any cacheable response that is subject to server-driven negotiation.
Doing so allows a cache to properly interpret future requests on that resource and informs the user agent about the presence of negotiation on that resource.

A direct integration in Rails is available since version 6.1 Show archive.org snapshot . In earlier versions, you will need to set it manually.

If possible, you may configure the header directly on the server. nginx is capable of this, for example.


Another way is to use a custom middleware that catches the request and peppers the response with the Vary-header:

class CacheControl::Middleware

  MAX_AGE = 60

  def initialize(app)
    @app = app
  end

  def call(env)
    request = ::Rack::Request.new(env)
    status, headers, body = @app.call(env)
    
    # Add surrounding caching logic as needed to determine if you want to cache
    cache!(headers)
    headers['Vary'] = 'Accept'
    
    [status, headers, body]
  end
  
  def cache!(headers)
    headers['Cache-Control'] = if Rails.env.production? || Rails.env.staging?
      "max-age=#{MAX_AGE}, public"
    else
      # ...
    end
  end
  
end

In an initializer of your choice, e.g. add:

Rails.application.config.middleware.insert_before ActionDispatch::Executor, CacheControl::Middleware
Dominic Beger
Last edit
Felix Eschey
Keywords
security, vulnerability, attack
License
Source code in this card is licensed under the MIT License.
Posted by Dominic Beger to makandra dev (2021-12-01 17:56)