Cache Poisoning in Phoenix with Mutual Tls
Cache Poisoning in Phoenix with Mutual Tls — how this specific combination creates or exposes the vulnerability
Cache poisoning occurs when an attacker tricks a cache (e.g., CDN, reverse proxy, or in-memory cache) into storing malicious content that is then served to other users. In Phoenix, this typically involves manipulating HTTP cache-related headers such as Cache-Control, Vary, and ETag to cause different responses to be cached under the same key. When mutual TLS (mTLS) is enforced between the client and server, the assumption is often made that the client identity is verified and that requests are therefore safe to cache or reuse. However, mTLS does not inherently prevent cache poisoning; it only authenticates the client to the server. If a Phoenix endpoint uses mTLS for client authentication but still varies cache keys on insufficient attributes (e.g., omitting the request body or specific headers), an authenticated client can submit a request that causes a poisoned cache entry to be stored and later served to other authenticated clients.
For example, an authenticated client with a valid mTLS certificate could send a request with a manipulated Accept-Language header and a poisoned Cache-Control: public directive. If the Phoenix controller caches the response based only on the path and query parameters, other clients with different language preferences may receive the incorrect, potentially malicious content. This becomes particularly dangerous when sensitive data or security-related headers are involved, as the cached response may inadvertently expose private information or execute unintended logic. Because mTLS ensures the client is known, developers might incorrectly trust the request and relax input validation or cache key design, inadvertently expanding the attack surface. The combination of strong client authentication and improperly designed caching logic can therefore amplify the impact of cache poisoning by making poisoned entries more likely to be reused across trusted clients.
Compounding the issue, Phoenix applications that rely on Elixir plug pipelines may inadvertently cache responses at the plug level when cache_resp or similar mechanisms are used without carefully scoping the :cacheable options. If the pipeline does not include the full set of differentiating headers or the request body in the cache key, an attacker can exploit this gap. Using mTLS also means that logging and monitoring may assume encrypted client identities provide sufficient context, potentially obscuring anomalies in cache behavior. This highlights the need to treat mTLS as an authentication layer, not a safeguard against injection via cache mechanisms. Proper remediation requires aligning cache key construction with the trust boundary introduced by mTLS and ensuring that all inputs that affect the response are part of the cache key, while also validating and normalizing headers before caching.
Mutual Tls-Specific Remediation in Phoenix — concrete code fixes
To mitigate cache poisoning in Phoenix while using mutual TLS, focus on ensuring that cache keys incorporate all request attributes that influence the response, including headers and body when relevant, and avoid trusting mTLS alone to prevent injection. Below are concrete code examples that demonstrate secure practices.
First, define a function to build a robust cache key that includes the request path, selected headers, and, if applicable, a hash of the body. This prevents different clients from sharing the same cached response when headers such as Accept-Language or custom authentication headers differ.
defmodule MyApp.Cache do
def cache_key(conn) do
path = conn.request_path
headers = %{
"accept-language" => conn.req_headers["accept-language"],
"authorization" => conn.req_headers["authorization"]
}
body_hash = hash_body(conn)
:erlang.term_to_binary({path, headers, body_hash})
end
defp hash_body(%{body_params: body}) when not is_nil(body) do
:crypto.hash(:sha256, Jason.encode!(body))
end
defp hash_body(_), do: :crypto.hash(:sha256, <<>>)
end
Next, integrate this key into your pipeline with a plug that caches responses only when safe, ensuring that sensitive or user-specific responses are not marked as public. Use Plug.Cache with explicit vary headers and avoid caching responses that contain private data.
defmodule MyApp.Pipeline do
use Plug.Router
plug Plug.Parsers, parsers: [:urlencoded, :json]
plug Plug.Cache,
cache_opts: [cache: MyApp.CacheStore],
key: &MyApp.Cache.cache_key/1,
cacheable_statuses: [200],
vary: ["Accept-Language", "Authorization"]
plug :match
plug :dispatch
get "/profile" do
# Ensure no sensitive data is cached inadvertently
send_resp(conn, 200, Jason.encode!(%{user: conn.assigns.current_user}))
end
end
On the mTLS side, verify client certificates explicitly and bind the verified peer identity to the connection assigns without assuming it is sufficient for caching. In your endpoint configuration, enforce client verification and map the certificate subject to a conn assign for later use in logging or authorization, but do not rely on it to determine cacheability.
defmodule MyApp.Endpoint do
use Plug.Cowboy, otp_app: :my_app, scheme: :https
def init(_opts) do
[
port: 443,
certfile: "priv/cert.pem",
keyfile: "priv/key.pem",
cacertfile: "priv/ca.pem",
verify: :verify_peer,
depth: 10,
customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
]
end
def handle(conn, _opts) do
client_cert = Plug.SSL.client_certificate(conn)
subject = extract_subject(client_cert)
assign(conn, :client_subject, subject)
end
defp extract_subject(cert) when not is_nil(cert) do
{:OTPCertificate, der} = :public_key.pkix_cert_decode(cert)
{:TBSCertificate, tbs} = :public_key.pkix_extract_tbs(der)
tbs[:subject]
end
defp extract_subject(_), do: nil
end
Finally, ensure that responses containing sensitive or user-specific data set appropriate Cache-Control headers to prevent caching by intermediaries. In Phoenix controllers, explicitly set headers to restrict caching when the response includes client-specific information, even when mTLS is enforced.
defmodule MyAppWeb.ProfileController do
use MyAppWeb, :controller
def show(conn, _params) do
conn
|> put_resp_header("cache-control", "no-store, no-cache, must-revalidate")
|> json(%{profile: build_profile(conn.assigns.client_subject)})
end
end