Are you absolutely sure your Rails caching strategy isn't leaking sensitive information?

Steve Polito

Imagine we have a partial that renders a product’s attributes. If the person viewing the product is an admin, we render an additional set of attributes not intended for the general public to see.

<% # app/views/products/_product.html.erb %>
<div id="<%= dom_id product %>">
  <h2><%= product.name %></h2>
  <p>
    <strong>Price:</strong>
    <%= product.price_in_cents.to_fs(:currency) %>
  </p>
  <% # data intended for admins only %>
  <% if admin? %>
    <p>
      <strong>Wholesale price:</strong>
      <%= product.wholesale_price_in_cents.to_fs(:currency) %>
    </p>

    <p>
      <strong>Supplier:</strong>
      <%= product.supplier %>
    </p>
  <% end %>
</div>

In an effort to improve performance, we use fragment caching to cache each product on the page.

<% @products.each do |product| %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

When your application receives its first request to this page, Rails will write a new cache entry.

Unfortunately, this means that if the first request comes from an admin, the cache will be written using values not intended for the general public to see.

A list of products as seen by a non-admin where attributes intended for the
admin are visible

One way to prevent this is to call uncacheable! inside the partial.

  <% # app/views/products/_product.html.erb %>
+ <% uncacheable! if admin? %>
  <div id="<%= dom_id product %>">
    <h2><%= product.name %></h2>
    <p>
      <strong>Price:</strong>
      <%= product.price_in_cents.to_fs(:currency) %>
    </p>
    <% # data intended for admins only %>
    <% if admin? %>
      <p>
        <strong>Wholesale price:</strong>
        <%= product.wholesale_price_in_cents.to_fs(:currency) %>
      </p>

      <p>
        <strong>Supplier:</strong>
        <%= product.supplier %>
      </p>
    <% end %>
  </div>

Now if the first request is made by an admin, the application will raise UncacheableFragmentError.

An error screen showing UncacheableFragmentError is raised

Although this will prevent the cache from being written, it also breaks the application for that admin. An improvement is to conditionally write to the cache based on if the request came from an admin by using cache_unless?.

<% @products.each do |product| %>
- <% cache product do %>
+ <% cache_unless admin?, product do %>
    <%= render product %>
  <% end %>
<% end %>

Now if the first request is made by an admin, we do not cache the result. We only cache the result once the first request comes from a non-admin.

The same list of products as seen by a non-admin. This time, the attributes
intended for the admin are no longer visible

An alternative approach is to pass both dependencies as part of an array.

<% @products.each do |product| %>
- <% cache product do %>
+ <% cache [product, admin?] do %>
    <%= render product %>
  <% end %>
<% end %>

If the product is updated, or the value of admin? changes, then the cache will be broken.

The same vulnerability exists for collection caching too. If an admin makes the first request to the page, the cache will be written with data intended for an admin.

<%= render partial: 'products/product', collection: @products, cached: true %>

Unfortunately, calling uncacheable! has no effect here since we are no longer using a cache block.

Instead, we can scope the cache to multiple dependencies like we did with the previous fragment cache implementation.

<%= render partial: 'products/product', collection: @products, cached: -> product { [ product, admin? ] }%>