Cache Poisoning in Phoenix with Cockroachdb
Cache Poisoning in Phoenix with Cockroachdb — how this specific combination creates or exposes the vulnerability
Cache poisoning in a Phoenix application that uses CockroachDB typically occurs when an attacker can influence cache keys or cached responses in a way that causes incorrect or malicious data to be served. Because Phoenix often relies on external data stores like CockroachDB for consistency and durability, cached representations of database rows or query results must be carefully isolated per tenant, user, or request context.
With CockroachDB, a distributed SQL database, cache poisoning risks are amplified when cache keys do not incorporate tenant identifiers, schema versions, or request-scoped parameters. For example, caching a SQL query result keyed only by table name and primary ID can cause one tenant’s data to be returned to another tenant if the cache is shared across requests. This is a form of Insecure Direct Object Reference (IDOR) realized through the cache layer rather than the database layer.
Phoenix pipelines that include Ecto queries such as Repo.get!(User, id) or filtered queries like Repo.all(from u in User, where: u.tenant_id == ^tenant_id) must ensure that cache keys embed the tenant_id and any relevant context. If caching is performed at the Phoenix view or controller layer without scoping, an attacker may supply an ID that maps to another user’s record, and the cached response for that ID could be returned inadvertently, leading to data exposure or privilege escalation.
Additionally, if the cache TTL is long and the invalidation strategy is weak, poisoned entries can persist across deployments or tenant migrations. CockroachDB’s strong consistency helps ensure that queries return the latest committed data, but caching layers that do not respect transaction boundaries or session-specific settings can present stale or manipulated data as authoritative. This misalignment between database guarantees and cache behavior is where the vulnerability manifests.
In a typical attack chain, an unauthenticated endpoint that returns user profile information might use a cache key such as "user_profile_#{id}". An attacker iterating over numeric IDs could read other users’ profiles if cache entries were previously populated by other users. With CockroachDB, even if the database enforces row-level checks, the cache bypasses those checks entirely, making input validation and per-request cache scoping essential.
Cockroachdb-Specific Remediation in Phoenix — concrete code fixes
To mitigate cache poisoning when using Phoenix and CockroachDB, scope cache keys to the full request context and enforce tenant isolation at both the cache and query layers. Below are concrete, working examples using Ecto and Phoenix.
1. Tenant-aware cache keys
Always include tenant_id and user_id in cache keys. Example in a Phoenix controller using cache/1 and Phoenix.Cache:
def show(conn, %{"id" => id}) do
tenant_id = conn.assigns.current_tenant.id
user_id = conn.assigns.current_user.id
cache_key = {"tenant/#{tenant_id}/user_profile/#{user_id}", id}
profile = Cachex.fetch!(:my_cache, cache_key, ttl: :timer.minutes(5)) do
Repo.get!(User, id)
end
render(conn, "show.json", data: profile)
end
2. Ecto query scoping with tenant_id
Ensure every Ecto query includes tenant_id filtering. Do not rely on cached representations without scoping:
def get_user_for_tenant(repo, tenant_id, user_id) do
repo.get!(User, user_id)
# Alternatively, use a query that enforces tenant_id:
from(u in User, where: u.id == ^user_id and u.tenant_id == ^tenant_id)
|> repo.one!()
end
3. Cache invalidation tied to transaction outcomes
Use Repo transaction events or explicit cache purges after writes to avoid stale poisoned entries:
Repo.transaction(fn ->
Repo.insert!(%User{tenant_id: tenant_id, name: "Alice"})
Cachex.del(:my_cache, {"tenant/#{tenant_id}/user_list", 0})
end)
4. Avoid global or numeric-only keys
Do not use keys like "user_#{id}" without tenant context. Instead, namespace by tenant and operation:
# Bad
Cachex.put(:my_cache, "user_123", data)
# Good
Cachex.put(:my_cache, {"tenant/abc123/user/123", :profile}, data)
5. Validate input before cache lookup
Ensure IDs are UUIDs or integers that belong to the requesting tenant before attempting cache or database operations:
def safe_show(conn, %{"id" => raw_id}) do
with {:ok, id} <- sanitize_id(raw_id),
true <- tenant_owns_id?(conn.assigns.current_tenant.id, id) do
render(conn, "show.json", data: Repo.get!(User, id))
else
_ -> send_resp(conn, 403, "Forbidden")
end
end
6. Use consistent serialization and schema versioning in cache
When storing complex structures, include schema version and tenant context to avoid replaying cached data across different application versions:
cache_entry = %{
version: "v1",
tenant_id: tenant_id,
data: Repo.get!(User, id)
}
Cachex.put(:my_cache, {"tenant/#{tenant_id}/user/#{id}/v1", 0}, cache_entry)