Cache Poisoning in Express
How Cache Poisoning Manifests in Express
Cache poisoning in Express applications occurs when an attacker manipulates cache keys or injects malicious content that gets stored and served to other users. This vulnerability is particularly dangerous in Express because of its flexible middleware system and common caching patterns.
The most common Express-specific manifestation involves improper handling of Vary headers. Consider this vulnerable pattern:
app.get('/api/user', (req, res) => {
const userId = req.query.userId || req.user.id;
const user = getUserById(userId);
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(user);
});This endpoint is vulnerable because it doesn't properly vary the cache by user identity. An attacker can request /api/user?userId=1 then /api/user?userId=2, potentially causing the cache to serve user 1's data to user 2.
Another Express-specific pattern involves middleware ordering. When caching middleware is placed before authentication:
const cache = require('apicache');
app.use(cache.middleware('5 minutes'));
app.use(authenticate);
app.get('/protected', (req, res) => {
res.json({ data: sensitiveInfo });
});Even though authentication runs, the cache stores responses before authentication completes, allowing unauthenticated users to access cached protected data.
Query parameter manipulation is another attack vector. Express applications often use query parameters for filtering or pagination:
app.get('/search', (req, res) => {
const { query, page = 1 } = req.query;
const results = searchDatabase(query, page);
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(results);
});An attacker can craft specific query combinations that cause the cache to store poisoned responses, which are then served to legitimate users searching for similar terms.
Header-based cache poisoning is also Express-specific. Applications often use custom headers for API versioning or feature flags:
app.get('/api/v1/data', (req, res) => {
const version = req.get('X-API-Version') || '1.0';
const data = getDataForVersion(version);
res.setHeader('Cache-Control', 'public, max-age=600');
res.json(data);
});Without proper Vary header configuration, different API versions can overwrite each other in the cache, potentially serving newer data to older clients or vice versa.
Express-Specific Detection
Detecting cache poisoning in Express requires examining both code patterns and runtime behavior. Start by auditing your middleware stack and caching configuration.
Code analysis should focus on these Express-specific patterns:
# Check for caching middleware placement
grep -r "cache" routes/ --include="*.js"
grep -r "Cache-Control" routes/ --include="*.js"
Look for caching middleware like apicache, express-redis-cache, or node-cache and verify they're placed after authentication and authorization middleware.
Runtime detection involves monitoring cache keys and hit rates. Use Express middleware to log cache operations:
const cache = require('apicache');
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
if (res.get('X-Cache')) {
console.log(`Cache ${res.get('X-Cache')} for ${req.path}`);
}
});
next();
});middleBrick's Express-specific scanning can automatically detect these patterns by analyzing your running application. It tests for cache poisoning by:
- Checking Vary header completeness for user-specific endpoints
- Testing cache key collisions across different user contexts
- Verifying caching middleware ordering relative to auth middleware
- Analyzing query parameter handling for cache poisoning vectors
The scanner creates test requests with manipulated parameters and headers to observe cache behavior, identifying where user data might be improperly shared.
Express-Specific Remediation
Remediating cache poisoning in Express requires both architectural changes and proper header configuration. The most critical fix is ensuring proper Vary header usage:
app.get('/api/user', (req, res) => {
const userId = req.user.id;
const user = getUserById(userId);
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Vary', 'Cookie, Authorization, User-ID');
res.json(user);
});This ensures the cache key includes user identity information, preventing cross-user data leakage.
Middleware ordering is equally important. Always place authentication before caching:
app.use(authenticate);
app.use(authorize);
app.use(cache.middleware('5 minutes'));
app.get('/protected', (req, res) => {
res.json({ data: sensitiveInfo });
});For query parameter endpoints, implement strict cache key generation:
const generateCacheKey = (req) => {
const { query, params, user } = req;
return `${req.path}:${JSON.stringify({
query: query,
params: params,
userId: user?.id
})}`;
};
app.get('/search', (req, res) => {
const key = generateCacheKey(req);
// Check cache with generated key
// Process request and store with proper key
});Consider using cache-busting techniques for sensitive endpoints:
app.get('/api/sensitive', (req, res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.json({ sensitive: data });
});For applications using Redis or other external caches, implement namespace isolation:
const cache = require('apicache');
const namespace = process.env.NODE_ENV || 'development';
const namespacedCache = {
get: (key) => cache.get(`${namespace}:${key}`),
set: (key, value, ttl) => cache.set(`${namespace}:${key}`, value, ttl)
};
Finally, implement cache validation to prevent stale data serving:
app.use((req, res, next) => {
const cached = res.get('X-Cache');
if (cached && cached !== 'MISS') {
const lastModified = req.get('If-Modified-Since');
if (lastModified) {
const cacheTime = new Date(cached.split(' ')[1]);
if (cacheTime.getTime() > new Date(lastModified).getTime()) {
res.setHeader('Cache-Control', 'must-revalidate');
}
}
}
next();
});