Cache Poisoning in Rails with Basic Auth
Cache Poisoning in Rails with Basic Auth — how this specific combination creates or exposes the vulnerability
Cache poisoning occurs when an attacker causes a cache to store malicious content, leading other users to receive that content. In Rails applications that use HTTP Basic Auth, this risk is heightened when authentication decisions are not properly considered before caching responses. Because Basic Auth is typically enforced via request headers (the Authorization header), cached responses that vary by headers must include Vary: Authorization to prevent one user’s authorized response from being served to another user.
If a Rails endpoint caches without accounting for the Authorization header, an attacker who can observe or guess a victim’s authenticated response (for example, by making a request with a known credential) might poison the cache. Subsequent unauthenticated or low-privilege requests to the same URL could then receive the cached response intended for an authorized user, exposing sensitive data or functionality. This becomes critical when Rails caches at the web server, CDN, or application level without incorporating header variations into the cache key.
Consider a Rails controller that serves user-specific data and relies on HTTP Basic Auth for access control:
class Api::V1::ReportsController < ApplicationController
before_action :authenticate_with_basic_auth
def show
@report = current_user.reports.find(params[:id])
render json: @report
end
private
def authenticate_with_basic_auth
authenticate_or_request_with_http_basic do |username, password|
User.authenticate(username, password)
end
end
end
If this endpoint is cached without accounting for the Authorization header, two users with different credentials could receive each other’s reports from the cache. Additionally, because Basic Auth credentials are base64-encoded but not encrypted, caching intermediaries that store responses without Vary: Authorization can inadvertently expose authorization boundaries.
Rails’ default caching mechanisms do not automatically incorporate Authorization headers into cache keys. Therefore, developers must explicitly ensure that cache keys or cache store configurations incorporate header-derived variability when authentication is required. Without this, even if the application layer enforces authentication, intermediate caches may serve unauthorized content, undermining access controls.
Basic Auth-Specific Remediation in Rails — concrete code fixes
To mitigate cache poisoning when using Basic Auth in Rails, ensure that responses are cached with a key that reflects the Authorization header or that caching is bypassed for authenticated endpoints. Below are concrete remediation approaches with code examples.
1) Include Authorization in the cache key
When fragment or low-level caching is used, incorporate the Authorization header (or a sanitized representation) into the cache key. This ensures that each user’s cached response is stored and retrieved separately.
class Api::V1::ReportsController < ApplicationController
before_action :authenticate_with_basic_auth
def show
@report = current_user.reports.find(params[:id])
cache_key = "report/#{params[:id]}/user/#{current_user.id}"
@report = Rails.cache.fetch(cache_key) do
@report.tap { |r| r.load_details } # simulate expensive operation
end
render json: @report
end
private
def authenticate_with_basic_auth
authenticate_or_request_with_http_basic do |username, password|
User.authenticate(username, password)
end
end
end
2) Use Vary: Authorization for HTTP caches
If you rely on HTTP-level or CDN caching, configure Rails to add Vary: Authorization to responses. This instructs shared caches to store distinct copies per Authorization header value.
class Api::V1::ReportsController < ApplicationController
before_action :authenticate_with_basic_auth
after_action :set_vary_header
def show
@report = current_user.reports.find(params[:id])
render json: @report
end
private
def authenticate_with_basic_auth
authenticate_or_request_with_http_basic do |username, password|
User.authenticate(username, password)
end
end
def set_vary_header
response.headers['Vary'] = 'Authorization'
end
end
3) Skip caching for authenticated endpoints
When precise cache key derivation is complex, disable caching for actions that rely on Basic Auth. This eliminates the risk of poisoning at the cost of increased origin load.
class Api::V1::ReportsController < ApplicationController
before_action :authenticate_with_basic_auth
before_action :skip_cache, only: [:show]
def show
@report = current_user.reports.find(params[:id])
render json: @report
end
private
def authenticate_with_basic_auth
authenticate_or_request_with_http_basic do |username, password|
User.authenticate(username, password)
end
end
def skip_cache
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
end
end
4) Use scoped authentication instead of Basic Auth for APIs
For new APIs, prefer token-based schemes (e.g., Bearer tokens) and use Rails’ built-in mechanisms or libraries that integrate cleanly with Rails caching and vary-by headers. If Basic Auth is required, ensure credentials are not logged or cached in clear form.
class Api::V1::BaseController < ApplicationController
before_action :set_cache_vary_headers
private
def set_cache_vary_headers
response.headers['Vary'] = 'Authorization'
end
end
Always validate that caches differentiate users by authorization context and that sensitive responses are not stored in shared caches.