Treat a shared cache as a second authorization point. Key entries on the full request target plus every input that changes the response, never store or serve responses to requests that carry credentials, and send Cache-Control: private or no-store on personalized responses. Make the cache and origin interpret paths alike, so a private URL never passes for a static file. Test with two users and a cold cache, and keep a public hit as the positive control.
- Cache poisoning stores a harmful response under a key that ordinary requests share; cache deception gets a private response classified as public.
- RFC 9111 forbids a shared cache to reuse a response to a request with
Authorizationunless the response allows it, but nginx 1.30 does not apply that rule; bypass credentialed requests yourself. - nginx keys on
$scheme$proxy_host$request_uriby default, so the query is included; a key that drops it creates the locale collision in C1. - Rules such as "
*.cssis public" turn path confusion into deception; PortSwigger's 2024 research reached them through delimiters and normalization differences. - After a leak, purge every cache layer: changing the origin header does not remove entries already stored.
| Setting | Value | Note | Source |
|---|---|---|---|
| Personalized responses | Cache-Control: private, no-store | — | RFC 9111: HTTP caching semantics |
Requests with Authorization or a session cookie | Bypass lookup and storage (proxy_cache_bypass and proxy_no_cache in nginx) | — | RFC 9111: HTTP caching semantics |
| Cache key | Scheme, host and the complete request target, query included | nginx default: $scheme$proxy_host$request_uri. | NGINX proxy cache configuration |
Responses with Vary: * or Set-Cookie | Not stored (nginx default) | — | NGINX proxy cache configuration |
Cache rules versus origin Cache-Control | Origin private and no-store must win over extension or path rules | — | PortSwigger Research: Gotta cache 'em all (2024) |
- With a cold cache, request a personalized URL as user A, then as user B; B must receive its own data and the cache status must be
BYPASSorMISS. - Request the same URL anonymously after an authenticated warm-up and confirm no personalized bytes come back.
- Request a public URL twice and confirm
MISSthenHITwith one origin request, so caching still works. - Append
.css,;x.cssand%2F..%2Fstaticvariants to a private route and confirm each is either rejected or returned withCache-Control: private, no-storeand never cached. - Run the local project with
EXERCISE_MODULE=reference node --test exercise.test.mjsand expect 12 passing tests.
- Applies to
- nginx 1.30.5 (configuration tested 2026-10-10)
Shared reverse proxies and application caches; the local project implements a deliberately narrow HTTP cache contract.
- Part of
- Workflows: Fix a finding, Test an application
Learning goals and prerequisites
What you will learn
- Identify response-changing inputs omitted from a cache key
- Separate cache poisoning from cache deception
- Preserve safe public hits while preventing private response reuse
Before you start
Developers and platform engineers responsible for application/CDN cache behavior
- Understand HTTP requests and response headers
- Distinguish public content from user-specific content
- For the project: Node.js 22+ and loopback server access
Refresh first: Keep HTTP request boundaries consistent across intermediaries
Go to the guided practiceA private page stored as a public file
- Crafted linkAn attacker gets a signed-in user to open
/account/export.css. - OriginRoute fallback sends the path to the private account page, which returns that user's data.
- Shared cacheA rule that treats
*.cssas public stores the private response. - Cache hitThe attacker requests the same URL and receives the stored response without the user's session.
A shared cache is a second response-serving component. On a hit it may return bytes before origin authentication, authorization or application logging executes. Its security contract therefore includes audience, representation, freshness and invalidation, not just a performance key. A perfectly correct origin permission check cannot fix a private response already served from an inappropriate cache entry.
Cache poisoning makes a request produce an undesirable response that is stored under a key also used by ordinary requests. Cache deception instead causes personalized content to be classified as publicly cacheable, often because the cache and origin interpret a path differently. Both involve a mismatch, but the first question in an investigation differs: which omitted input changed the response, or why was this private response considered eligible?
For C1, the origin includes locale in the catalogue response, but the cache key drops the query string. A request for French warms /catalog; a later English request receives the French representation. Keeping the complete request target fixes this particular collision. It does not settle whether the response is public or whether a future header starts changing it.
For C2, Alice warms /private and Bob receives Alice’s invoice on a hit. Adding tenant to the key still shares data among different users inside that tenant and ignores no-store. The local project takes a simple explicit policy: authenticated or cookie-bearing requests bypass both cache lookup and storage. This is the project’s contract; HTTP itself lets a shared cache store an authenticated response when the response explicitly allows it, for example with Cache-Control: public.
For C3, a synthetic origin routes every /account/... path to a private account controller. A synthetic cache rule classifies *.css as public. The private response for /account/export.css is now a candidate for public reuse even if the attacker never knew its contents in advance. The repair must address route fallback and cache eligibility. Renaming a URL alone leaves the disagreement available elsewhere.
C1: key=pathname; /catalog?locale=fr and /catalog?locale=en both become /catalog
C2: key=method+path; Alice GET /private stores Alice invoice; Bob hits same key
C3: cache marks *.css public; origin maps /account/* to current account
Project contract: only anonymous GET with no Cookie; exact public response directive; no Set-Cookie; no unsupported Vary.
C3 is a paper fixture; the local project implements C1/C2 and additional cache/header checks.Write the contract route by route. Which requests can read a shared entry? Which responses can create one? Which dimensions change its representation? How long is reuse permitted, and what invalidates it? A key cannot compensate for ineligible content, and checking storage alone cannot prevent a later unauthorized lookup of an existing entry.
RFC 9111 distinguishes private, no-store, freshness and revalidation. private constrains shared storage; no-store directs caches not to store. no-cache requires validation before reuse rather than simply forbidding storage. Authorization has special shared-cache rules with explicit exceptions. Use supported cache implementations for these semantics; a homemade string check is only appropriate when the producer and consumer deliberately enforce a narrow teaching contract.
Vary identifies request header dimensions used to select a representation. The project does not implement that machinery and therefore bypasses any response carrying Vary. It also refuses Set-Cookie responses under its conservative contract. These explicit limitations are preferable to claiming that a small map is a general HTTP cache. New origin behavior must update the cache contract and tests together.
nginx implements much of RFC 9111, but not its rule for Authorization. With a plain proxy_cache in nginx 1.30.5, an origin response marked only max-age=60 to a request carrying Authorization: Bearer alice was stored and then served to Bob and to an anonymous client. The configuration below closes that gap: any request with an Authorization header or a cookie bypasses both lookup and storage. In the same test Alice and Bob each received their own data, the public catalogue returned MISS then HIT with one origin request, ?locale=en got its own entry because the key keeps the full target, and a response marked private was not stored.
nginx also refuses to store responses with Set-Cookie or Vary: *, and stores other Vary values as separate variants. None of that protects a personalized response the origin labels as public, so the origin header and the cache rule must agree.
# Shared cache at the edge (nginx 1.30). nginx does not apply RFC 9111's Authorization rule,
# so requests that carry credentials must bypass the cache explicitly.
proxy_cache_path /var/cache/nginx/edge keys_zone=edge:10m max_size=1g inactive=10m use_temp_path=off;
map "$http_authorization$http_cookie" $credentialed {
"" 0;
default 1;
}
server {
listen 8080;
location / {
proxy_pass http://cacheorigin:8080;
proxy_cache edge;
proxy_cache_key $scheme$host$request_uri; # the full target, query included
proxy_cache_bypass $credentialed; # never answer a credentialed request from cache
proxy_no_cache $credentialed; # never store the response to one
add_header X-Cache-Status $upstream_cache_status always;
}
}Download the proxy/cache project linked with this guide and run node observe.mjs. Alice receives a MISS with tenant acme. Bob then receives a HIT containing Alice’s invoice. This is a real response from the local proxy; no network mock returns the expected data for the test.
Run node --test exercise.test.mjs. The starter passes four cases and fails eight. In exercise.mjs, preserve method and the full target in cacheKey, bypass authenticated/cookie requests in mayReadCache, and require the narrow public contract in mayStore. Keep validHeaders consistent with the framing guide at /domains/runtime/edge-defense/http-request-desynchronization/. The reference command EXERCISE_MODULE=reference node --test exercise.test.mjs passes all twelve tests.
Keep the public-cache positive control. Two anonymous /catalog requests must still produce MISS then HIT with one origin invocation. A patch that disables all cache reads avoids the disclosure but does not implement the stated functional requirement. Compare body content, cache status and origin count; a response header alone does not prove whose bytes were delivered.
A route that accepts arbitrary suffixes can turn a static-looking URL into a private controller. Prefer exact route matches for private features, reject unexpected suffixes, and derive eligibility from an explicit route/response contract rather than extension alone. Check encoded separators, normalization and redirects in your own stack; different intermediaries can transform a target at different stages. PortSwigger's 2024 research showed how: a semicolon that Spring treats as a delimiter, an encoded # or an encoded dot-segment can make a cache see /static/x.css while the origin serves /account. Its advice is to mark dynamic responses no-store and private, make sure cache rules cannot override Cache-Control, and avoid normalizing the cache key differently from the origin.
An origin that uses forwarded host or other client-influenced metadata to construct links can produce different responses for requests with the same cache key. Trust only headers established by a trusted proxy, as the API gateway inventory guide at /domains/runtime/api-security/api-gateway-inventory/ describes, strip client duplicates and keep canonical URL construction independent of untrusted forwarding inputs when possible. Adding every attacker-controlled header to the key can also create unbounded cache cardinality; the right repair may be to remove the input’s influence.
Cloudflare’s cache-deception protection compares extension and response type for a documented class of mismatch, with documented exceptions and override interactions. Treat it as a product-specific additional control. Confirm effective rule ordering and origin directives on the actual account; an enabled switch cannot establish that every personalized route is safe.
Use isolated caches and synthetic users for an authorized test. Start empty, warm using A, request using B, then reverse the order. Exercise an anonymous request after an authenticated warm-up, cookie-bearing requests, changed query/header dimensions, redirects and errors. Keep representative successful public hits. Record origin execution so a hidden bypass is visible.
After a confirmed leak, disable or repair the unsafe reuse rule and purge affected entries at every relevant layer. Changing an origin header does not necessarily remove entries already stored downstream. Bound the exposure window using cache lifetimes and available request evidence; avoid claiming that cache purge can recall responses already delivered to clients.
For a policy rollout, compare origin load as well as correctness. A cache fix can increase traffic to a previously bypassed backend. Capacity planning is not a reason to preserve a disclosure, but it is part of deploying the repair reliably. Document the owner who must update the cache contract when a route starts returning personalized data.
The project deliberately whitelists one public contract. This table is its acceptance matrix, not a general-purpose interpretation of every possible Cache-Control value.
| Response/request | Project result | Reason |
|---|---|---|
Anonymous /catalog, exact public directive | Store and reuse by method + complete target | Public and representation key is complete for this route |
Bearer token or Cookie on request | Bypass lookup and storage | Keep private/session traffic outside shared cache |
no-store /nonce | New origin request each time | Response is ineligible |
Vary: Accept | Bypass | This cache does not implement variant selection |
Set-Cookie response | Do not store | Conservative session boundary |
POST /echo with a different body | Do not reuse | The project only caches GET |
The project runs one Node origin and proxy with a bounded 30-second in-memory cache. It does not implement browser caching, a CDN, distributed invalidation, stale response rules or multi-region propagation. The deception scenario is a self-contained design review; it is not falsely presented as a feature of that executable. Finish by mapping the same cases to the actual cache product and saving observed outcomes against its effective configuration.
Guided practice
Apply the supplied cache/origin contracts, then repair the downloadable real cache implementation. Work through the paper deception case separately from the executable routes.
Supplied scenario material
T1: anonymous GET /catalog?locale=fr -> 200 {locale:fr}; Cache-Control: public, max-age=30.
T2: anonymous GET /catalog?locale=en -> same body from pathname-only cache.
T3: Alice GET /private -> 200 {tenant:acme,invoice:120}; Cache-Control: private, no-store.
T4: Bob GET /private -> T3 body on shared hit; origin receives no request.
T5: authenticated /account/export.css -> private account HTML at origin; synthetic edge rule ignores origin directive and marks *.css public.
T6: anonymous GET /catalog -> Vary: Accept; cache implementation has no variant support.
Safe public control: two anonymous /catalog requests with identical complete target and exact public directive.1. Diagnose representation identity
Identify the missing dimension in T1/T2 and state the smallest repair that resolves this fixture.
Expected result and reasoning
The locale query changes the origin representation but is omitted from the cache key. Preserve the complete target including the query. This addresses T1/T2 only; it does not make private content eligible or implement
Vary.2. Find the authorization bypass
Explain why adding a permission check inside the origin does not repair T4.
Expected result and reasoning
The shared cache returns the response without contacting the origin. Eligibility and lookup policy at that cache must prevent private reuse; origin permission checks still remain necessary for misses. Purge existing private entries as part of the repair.
3. Distinguish deception
Explain why T5 is different from changing the content of an otherwise public catalogue.
Expected result and reasoning
The victim’s private account response is misclassified as a static public asset because routing and extension-based eligibility disagree. Enforce exact private routing and private/no-store behavior at the cache; test the effective override rules. An extension or
Content-Typeguard is only an additional layer.4. Handle a feature you do not implement
Choose a result for T6 and justify why adding
Acceptonly sometimes is unsafe.Expected result and reasoning
Bypass the response until variant selection is deliberately implemented and tested. The supplied cache has no
Varymachinery. An occasional ad hoc header key can still miss other header dimensions and the changed contract must be reviewed end to end.5. Repair while retaining functionality
Run the local project and state the positive control that a blanket cache disable would fail.
Expected result and reasoning
Reference: 12/12 tests pass. Two identical anonymous public
/catalogrequests produceMISSthenHITwith one origin hit. The private, cookie,Vary,no-storeandPOSTcases must still reach the origin appropriately. Keep the observed leak trace and repaired result with the code change.
T5 is explicitly a paper deception fixture. The downloadable project uses loopback HTTP and validates the other named cache boundaries; test a production CDN separately in an isolated authorized environment.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Repair a real proxy and shared cache
Reproduce a private-response leak, repair cache policy and verify HTTP framing with real origin/proxy servers and raw socket requests.
In the extracted folder, run:
node observe.mjs
node --test exercise.test.mjs12 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Part of the learning path Browser and API boundaries
Actual HTTP/1.1 loopback traffic and a narrow cache contract; it is not a full HTTP cache or a test of your production intermediary chain.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- RFC 9111: HTTP caching semantics. June 2022.
- Cloudflare: avoid web cache poisoning
- Cloudflare: cache deception protection and exceptions
- NGINX proxy cache configuration
- RFC 9111 §3.5: storing responses to authenticated requests. RFC 9111 (June 2022).
- PortSwigger Research: Gotta cache 'em all (2024)
- PortSwigger Web Security Academy: web cache deception
Reference information
RFC 9111 and Node.js 22+; verify each deployed CDN configuration.
- SDLC phase
- DesignMaintenance
- ASVS 5.0.0 requirements
- v5.0.0-14.2.2 · v5.0.0-14.2.5
- CAPEC attack patterns