Cache Poisoning in Rails with Mutual Tls
Cache Poisoning in Rails with Mutual Tls — how this specific combination creates or exposes the vulnerability
Cache poisoning occurs when an attacker tricks an application into storing malicious content in a shared cache and subsequently serving that content to other users. In Rails, HTTP caching mechanisms such as page, action, and fragment caches as well as reverse proxies like Varnish or CDN integrations can be targeted. When mutual TLS (mTLS) is introduced, the security boundary changes: client certificates are used to authenticate clients, but the Rails app may still process and cache responses based on request attributes that are influenced by the client certificate or by variant headers that are mistakenly considered safe.
With mTLS, the server validates the client certificate during the TLS handshake before the HTTP layer sees the request. However, Rails may still vary the cache key on headers such as Accept, Accept-Encoding, or custom headers that an authenticated client can control. If the application caches at the reverse proxy or CDN level using request headers that differ per client certificate, a malicious certificate holder may be able to poison a cache entry that is later served to other clients. For example, an attacker could send a request with a crafted header that causes Rails to generate a response containing attacker-controlled data, and if the cache key does not exclude sensitive headers or the certificate context, subsequent requests from different clients could receive the poisoned response.
Another angle is that mTLS does not inherently prevent cache poisoning at the application layer. Rails’ built-in cache stores may still reflect path or parameter combinations that differ per client, and if the cache key is derived from user-supplied headers that are influenced by the authenticated identity, the same poisoned entry can be reused. Additionally, if the Rails app uses ESI or edge-side includes with cached fragments, an attacker could inject malicious fragments that are cached under a shared key. The use of mTLS ensures strong client authentication, but it does not sanitize inputs or normalize cache keys, so developers must explicitly exclude sensitive or variable headers from cache key derivation and carefully design cache scopes to avoid mixing content across clients.
Mutual Tls-Specific Remediation in Rails — concrete code fixes
To mitigate cache poisoning in a Rails app that uses mutual TLS, you must ensure cache keys do not incorporate headers or attributes that vary per authenticated client unless the cache is strictly partitioned by client identity. You should also enforce strict content normalization and selectively bypass caching for authenticated or sensitive requests. Below are concrete configuration and code examples for Rails with mTLS considerations.
1. Configure Rails to exclude sensitive headers from cache keys
Use a custom cache key generator that omits headers introduced or influenced by the client certificate or that may carry attacker-controlled values. For example, you can override the default cache key generation in an initializer:
# config/initializers/cache_key_generator.rb
module CacheKeyHelpers
def self.filtered_params(request)
# Exclude headers that may vary per mTLS client and should not affect shared cache
excluded_headers = %w[Authorization X-Client-Cert-Digest X-Forwarded-For Accept-Encoding]
request.headers.except(*excluded_headers).to_hash
end
end
Then use this filtered set when generating cache keys for fragment or low-level caching:
# app/models/concerns/cachable_with_mtls.rb
module CachableWithMtls
extend ActiveSupport::Concern
included do
def cache_key_with_mtls
# Use only safe parameters and a stable representation
klass.model_name.cache_key << "/#{id}-#{updated_at.utc.to_s(:number)}"
end
end
end
2. Use vary headers carefully and avoid caching sensitive responses
Set Vary headers explicitly to prevent caches from serving a response authenticated with one client’s certificate to another. In your controller, avoid caching responses that contain client-specific data:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :set_vary_header_for_mtls
private
def set_vary_header_for_mtls
# Instruct caches to vary by the client certificate fingerprint if used
request.headers["X-Client-Cert-Digest"]&.tap do |digest|
response.headers["Vary"] = "X-Client-Cert-Digest" if digest.present?
end
# Do not cache responses that depend on authorization context
response.headers["Cache-Control"] = "no-store" if user_signed_in?
end
end
3. Configure your web server or reverse proxy to exclude sensitive headers from caching
When terminating TLS at a load balancer or reverse proxy, ensure caching rules strip or ignore headers that should not be used for cache key derivation. Below is an example snippet for a proxy configuration that excludes certificate-derived headers from cache keys:
# Example pseudo-configuration for a reverse proxy
map_cache_key $http_x_client_cert_digest {}
proxy_cache_key "$scheme$request_method$host$request_uri$uri$is_args$args";
proxy_cache_bypass $http_x_client_cert_digest;
4. Validate and normalize inputs before caching
Even with mTLS, always validate and normalize inputs that influence cached content. For Rails, this means parameter filtering and strong parameters to avoid injecting unsafe values into cache keys or fragment names:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def show
@post = Post.find(params[:id])
# Use only permitted params for cache key derivation
cache_key = "post/#{@post.id}-#{params.permit(:locale, :version).to_h}"
Rails.cache.fetch(cache_key) do
render_show_for(@post)
end
end
end
5. Monitor and test cache behavior with authenticated mTLS clients
Verify that responses cached with one client certificate are not served to another by testing with distinct client certificates and inspecting Vary headers and cache keys. Incorporate integration tests that simulate authenticated requests via mTLS and assert that sensitive headers are excluded from cache normalization logic.