Code review is one of the most underrated skills in security. You don’t need a running application, a proxy, or a wordlist. Just the code — and the ability to read it like an attacker.
This one came from a Node.js Express application. A custom CDN caching middleware that looked perfectly reasonable on the surface. But hidden inside was a logic flaw that could expose any user’s sensitive account data to anyone who knew how to ask.
The Code
const cache = {};
dir public\function cdnMiddleware(req, res, next) {
const staticExts = ['.css', '.js', '.png', '.jpg', '.ico'];
if (staticExts.some(ext => req.path.endsWith(ext))) {
if (cache[req.path]) return res.send(cache[req.path]);
const origSend = res.send.bind(res);
res.send = (body) => { cache[req.path] = body; return origSend(body); };
}
next();
}
app.use(cdnMiddleware);
app.get('/account{/*path}', (req, res) => {
res.send('{"name":"[REDACTED]","email":"[REDACTED]","ssn":"[REDACTED]","balance":"[REDACTED]"}');
});
Take a minute to read it carefully before moving on.
Breaking It Down
The middleware has one job — cache static assets like CSS, JS, and images so they don’t have to be re-fetched on every request. Standard CDN behavior.
Here’s the logic:
- Check if the request path ends with a static file extension (
.css,.js,.png,.jpg,.ico) - If it does and the response is already cached — serve it from cache immediately
- If it’s not cached yet — intercept
res.send, cache the response body, then send it normally - If it’s not a static file — skip caching entirely and call
next()
Looks reasonable. But there’s a critical flaw hiding in step 1.
The Vulnerability — Path Confusion
The cache check uses:
req.path.endsWith(ext)
This checks whether the path string ends with a static extension. Nothing more. It doesn’t validate that the path actually points to a static file. It doesn’t check the Content-Type of the response. It just looks at the last characters of the URL.
Now look at the account endpoint:
app.get('/account{/*path}', (req, res) => {
This route uses a wildcard — /account{/*path} — meaning it matches any path that starts with /account/. That includes:
/account/profile
/account/settings
/account/dashboard.js ← ends with .js ✅
/account/details.css ← ends with .css ✅
/account/photo.png ← ends with .png ✅
The last three paths end with static file extensions — so the middleware treats them as static assets and caches whatever response they return.
The Attack
Here’s how an attacker exploits this:
Step 1 — Request the account endpoint with a path that ends in a static extension:
GET /account/details.js HTTP/1.1
Host: target.com
The middleware sees .js → cache miss → intercepts res.send → the account endpoint fires → returns the sensitive JSON response → middleware caches it under the key /account/details.js.
Step 2 — Now any subsequent request to /account/details.js — from any user, authenticated or not — gets served directly from the cache:
GET /account/details.js HTTP/1.1
Host: target.com
Cookie: (no cookie, unauthenticated)
Response:
{
"name": "Target User",
"email": "user@company.com",
"ssn": "XXX-XX-XXXX",
"balance": "$XXXXX"
}
Full account details — name, email, SSN, balance — served to an unauthenticated attacker from the cache. No credentials required after the initial poisoning request.
This is cache poisoning leading to information disclosure. The attacker poisons the cache once using their own session, and from that point on the sensitive data is publicly accessible to anyone.
Why This Is Dangerous
The account endpoint returns highly sensitive PII — social security numbers, financial balances, email addresses. Under normal circumstances this data is protected by authentication and authorization checks.
But the cache sits in front of those checks. Once the response is stored in the cache object, it bypasses every security control on subsequent requests. Authentication becomes irrelevant because the middleware returns the cached response before the request ever reaches the account handler.
The cache object is also stored in memory as a plain JavaScript object with no expiry, no eviction policy, and no size limit. Poisoned entries persist indefinitely until the server restarts.
Root Cause
Three separate issues combined to create this vulnerability:
1. Path-based cache key with no content validation
The cache key is just req.path. There’s no check that the path actually corresponds to a static asset or that the response Content-Type is appropriate for caching.
2. Wildcard route matching static extensions
The /account{/*path} wildcard allows paths ending in .js, .css, .png etc. to reach the account handler, satisfying the middleware’s static extension check.
3. No authentication check in the middleware
The middleware serves cached responses without verifying whether the requesting user is authorized to see them.
The Fix
Fix 1 — Cache based on Content-Type, not path extension:
function cdnMiddleware(req, res, next) {
const origSend = res.send.bind(res);
res.send = function(body) {
const contentType = res.getHeader('Content-Type') || '';
// Only cache actual static content types
if (contentType.includes('text/css') ||
contentType.includes('application/javascript') ||
contentType.includes('image/')) {
cache[req.path] = body;
}
return origSend(body);
};
next();
}
Fix 2 — Never cache responses that contain sensitive data:
// Mark sensitive routes explicitly
app.get('/account{/*path}', (req, res) => {
res.setHeader('Cache-Control', 'no-store, private');
res.send(accountData);
});
Fix 3 — Scope the wildcard route more tightly:
// Only match specific known account paths
app.get('/account/profile', handler);
app.get('/account/settings', handler);
// Not a catch-all wildcard
All three fixes should be applied together — defence in depth.
Key Takeaways
- Never use path string matching to determine if a response should be cached — use
Content-Typeheaders instead - Wildcards in route definitions are dangerous — they can allow unintended paths to reach sensitive handlers
- Caching layers bypass downstream security controls — treat the cache as a potential attacker entry point
- Sensitive endpoints must set
Cache-Control: no-store— this prevents any intermediate cache from storing the response - In code review, always ask: “What happens if I give this input an unexpected shape?” — that’s where the bugs live
Found this useful? More code reviews coming. Hit me up on X if you want to discuss.