Cache Poisoning in Koa with Dynamodb
Cache Poisoning in Koa with Dynamodb — how this specific combination creates or exposes the vulnerability
Cache poisoning in a Koa application that uses DynamoDB typically occurs when an application caches responses keyed by user-controlled input without sufficient validation or normalization. If the cache key incorporates parameters such as request headers, query strings, or unescaped item identifiers, an attacker can induce the server to store and later serve malicious or incorrect responses to other users.
DynamoDB itself does not introduce cache poisoning, but the way data is modeled and retrieved can affect risk. For example, using a composite key where the partition key is derived from an untrusted source (e.g., a header value or query parameter) and the sort key is insufficiently constrained can lead to unintended data access patterns. If responses for one set of parameters are cached under a key that does not sufficiently isolate tenant or user context, a poisoned entry may be returned to unrelated requests.
Consider a Koa endpoint that retrieves user profile data by user ID and includes the API version in the cache key. If the API version is taken directly from a request header and used as part of the cache key without normalization, two different clients sending the same user ID but different API versions could inadvertently share cached data. An attacker could force a cache entry under a benign version, then cause the application to read that entry under a different version where validation is weaker, exposing modified or sensitive content.
In DynamoDB terms, this can happen when conditional reads or queries are not strictly bound to a complete primary key. If the application performs a GetItem using only a partition key derived from a mutable header, and then caches the result, the same cached result might be reused for a different logical request that happens to map to the same key. This is especially relevant when using sparse indexes or secondary indexes where sort key design does not enforce strong scoping.
An example vulnerability chain:
- The client sends
GET /profile?userId=123with headerX-API-Version: v2. - The Koa handler builds a cache key
profile:v2:123and stores the DynamoDB response. - Later, another client sends
GET /profile?userId=123with headerX-API-Version: v1, but receives the cached v2 response, potentially bypassing v1-specific validation or filtering.
To mitigate this, cache keys must incorporate all dimensions that materially affect the response, including headers that change authorization, localization, or versioning. Responses should be cached per-tenant and per-context, and DynamoDB queries should always include explicit key conditions that prevent cross-context retrieval.
Dynamodb-Specific Remediation in Koa — concrete code fixes
Remediation focuses on strict key scoping, avoiding header-derived partition keys, and ensuring cache keys reflect the full request context. Below are concrete patterns for Koa with DynamoDB using the AWS SDK for JavaScript v3.
1. Use a deterministic, user-scoped primary key
Design your DynamoDB table so that the partition key includes a tenant or user scope, and avoid using mutable headers as key components.
const { DynamoDBClient, GetItemCommand } = require("@aws-sdk/client-dynamodb");
const client = new DynamoDBClient({ region: "us-east-1" });
async function getUserProfile(userId, tenantId) {
const command = new GetItemCommand({
TableName: "UserProfiles",
Key: {
pk: { S: `TENANT#${tenantId}` },
sk: { S: `USER#${userId}` }
}
});
const response = await client.send(command);
return response.Item;
}
2. Normalize inputs before constructing cache keys
Do not directly concatenate headers or query parameters into cache keys. Normalize and validate them first.
const normalizeHeader = (value) => {
if (!value) return "default";
return value.trim().toLowerCase().replace(/[^a-z0-9-]/g, "");
};
const apiVersion = normalizeHeader(ctx.get("X-API-Version"));
const cacheKey = `profile:${apiVersion}:${userId}`;
3. Include tenant and user context in cache keys
Ensure cache entries cannot be shared across tenants by embedding the tenant identifier in the key.
function buildCacheKey(tenantId, userId, endpoint, version) {
return `cache:${tenantId}:${userId}:${endpoint}:${version}`;
}
// Example usage in a Koa middleware
const tenantId = ctx.state.tenant.id;
const userId = ctx.state.user.id;
const endpoint = "profile";
const version = normalizeHeader(ctx.get("X-API-Version"));
const key = buildCacheKey(tenantId, userId, endpoint, version);
4. Use conditional reads to enforce ownership
When retrieving items, enforce that the request’s tenant matches the item’s tenant attribute to prevent cross-tenant reads.
async function getScopedItem(partitionKey, sortKey, tenantId) {
const command = new GetItemCommand({
TableName: "UserProfiles",
Key: {
pk: { S: partitionKey },
sk: { S: sortKey }
},
ConditionExpression: "tenantId = :tid",
ExpressionAttributeValues: {
":tid": { S: tenantId }
}
});
const response = await client.send(command);
if (!response.Item) {
throw new Error("Item not found or access denied");
}
return response.Item;
}
5. Avoid caching sensitive or version-sensitive responses without full context
If responses vary by header, include those headers in the cache key or bypass caching entirely for sensitive endpoints.
const shouldCache = (ctx) => {
const auth = ctx.get("authorization");
const sensitiveHeaders = ["authorization", "x-admin-token"];
return !sensitiveHeaders.some((h) => ctx.get(h));
};
if (shouldCache(ctx)) {
// store and retrieve from cache
} else {
// proceed without caching
}