Caching and disclosure
Published
A caching layer is configured by telling it what NOT to do: do not store this one, do not let a shared cache keep that one, key on more than the path, vary on the header that decides who the response is for, and never store a response that sets a cookie. Every one of those is a property name on an options object, read by a layer that came out of a package or runs at an edge you do not build. We protected a file that configures one, renamed the names a group at a time, and looked at what the next visitor received.
What the sample actually does
The file puts eight requests through a caching layer in front of an origin. Two anonymous visitors ask for the price page in different languages. A signed-in customer loads an account page that carries their email address, the last four digits of a card, and a rotated session cookie. An anonymous visitor then asks for the same path. A signed-in customer loads a dashboard that is personal but not secret. A search request whose query is reflected into the body is followed by an ordinary search. And a price change is followed by a visitor arriving after the lifetime the application pinned.
Each of those exists to make one guard fire exactly once. That matters more here than in most areas, because the guards overlap: a response marked no-store never reaches the question of whether a shared cache may keep it, so measuring the shared-cache option needs a route where no-store is not already refusing. The dashboard is that route.
The layer is copied in unprotected, with the defaults this kind of component ships: publicly cacheable, stored, no vary, ten minutes in the shared cache, keyed on the path alone, and willing to store any successful response.
Protection alone was applied first on five presets and all five behaved identically to the original.
The whole-block arm hands a stranger a session cookie
With the options object renamed as a group, the account page - which the application marks no-store, private, and varying on the header that identifies the caller - was stored in the shared cache. The next request for that path, from a visitor holding no credentials at all, was answered out of the cache rather than by the origin.
That response carried the signed-in customer's email address and the card suffix. It also carried the Set-Cookie header that rotates their session, which means the anonymous visitor's browser was handed a live session identifier belonging to somebody else. The measurement prints it. The dashboard went the same way, and the German visitor got the English page, and the poisoned search result was served to the next searcher, and the price that had changed was still the old one.
It is worth being clear that this is not an exotic failure. It is the ordinary behaviour of a cache that was told nothing, which is what a cache is when the names it reads have moved. The application still emitted a Cache-Control header on every response; it read public, s-maxage=600.
Two of those options are individually harmless, which is the interesting part
Renaming the shared-cacheability option alone moved the dashboard from the browser's own cache into the shared one - the entry count went up and the directive changed from private to public - and the anonymous visitor still could not read it, because the vary rule still keyed the entries apart. Renaming the vary rule alone removed the keying but the dashboard was still private, so there was no shared entry to collide over.
Renaming both is what serves one customer's dashboard to the next visitor. That is rule fourteen of this series holding for the eighth consecutive pass: a guard expressed as two options fails safe when you lose either half and fails open when you lose both. It is also a warning about how these results get triaged. The half-arms produce a change you can see in a header diff and no change in behaviour, which is exactly the kind of finding that gets closed as cosmetic.
The no-store option is the third member of that group and behaves the same way. On its own it moves the account page from not stored to stored in the browser only - still private - and the disclosure needs the shared-cacheability option to go with it.
The key parts, and a guard written as a list of strings
The option that says what the cache key is built from is a list of strings: the path, the query, the tenant. Renaming it reverts the layer to its default, which is the path alone.
The consequence is cache poisoning by unkeyed input. The sample sends a search whose query is reflected into the response body, then an ordinary search for the same path. In the unprotected run they are different entries and the second search gets its own results. With the key parts gone, the second search was answered out of the cache with the first one's body - an image tag with an error handler that calls out to a collector, now served to a visitor who never asked for it.
There is a second lesson in the same place. Because the key parts are STRINGS, and the layer reads the request with bracket access using those strings, renaming the request object's own property names does not quietly change the key - it crashes, because the strings still say path and the property is now called something else. That is the same asymmetry the previous pass wrote down as a standing rule: a security rule expressed as data does not move when the property name it refers to moves. Here it produced a loud failure rather than a silent one, which is the good case, and only because the read is a bracket access against a literal list.
The lifetime, and why nobody reports this one
Renaming the shared-lifetime option took it from the thirty seconds the application pinned to the layer's ten-minute default. The price changed at the origin and the visitor who arrived afterwards was served the old one out of the cache.
A cache lifetime is the upper bound on how long any change takes to bite: a price, a permission, a feature flag, a takedown. Ten minutes is not a long time, and that is the problem - it is short enough that everyone assumes the report is user error and long enough to matter for the things you care about most. Nobody opens a ticket saying the page was ten minutes stale.
The caller-supplied cacheability test is this area's downgraded guard: the application refuses to store anything that sets a cookie, whatever its status code, and the layer's own builtin stores any successful response. Renaming that one name is what makes the session cookie in the whole-block arm reachable at all.
The option pinned equal to the library default changed nothing, exactly as predicted, for the ninth consecutive pass.
What to do about it
The mechanism is the same one this series keeps measuring, with one aggravating feature specific to caching: the failure is not in your process. By the time a shared cache holds a private response, the disclosure is a matter of who requests that path next, and it will keep happening until the entry expires - which is governed by the lifetime option, which is in the same group.
Scope the renaming. The options object handed to a caching layer, and the request-shape properties any key function reads, belong outside the MemberRegexp. If you would rather not maintain an exclusion list, write the policy objects with quoted string keys and read them with literal bracket access.
The assertion that catches this area is on the response, and it is cheap. For one authenticated route, require the emitted Cache-Control to contain both no-store and private, and require the Vary header to name the header that identifies the caller. Then add one end-to-end check: request an authenticated page, then request the same path with no credentials, and fail if the second response contains anything from the first. That single check fails on the whole-block arm, on the paired arm and on the no-store arm.
Monitoring will not do this for you. Every arm in this article improves your cache hit rate.
Frequently asked questions
Does protecting my JavaScript break HTTP caching on its own?
Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at the option names.
What was the worst outcome measured?
An anonymous visitor received a signed-in customer's account page out of a shared cache, including their email address, a card suffix, and the Set-Cookie header carrying that customer's session identifier.
Why did renaming the private directive alone not leak anything?
Because the vary rule still keyed the entries apart, so the anonymous request did not match the stored one. The disclosure needs both names to be lost, which is why an exclusion list covering one of the pair is worth very little.
How does a query string that is not part of the key cause a problem?
The cache stores one entry for the path and serves it to everybody. If the response reflects the query, the first request decides what every later visitor to that path sees, which is cache poisoning by unkeyed input.
Would our monitoring catch a private page being cached?
No, and it will look like an improvement. Serving personalised responses from a shared cache raises the hit rate and lowers origin load, which is what most cache dashboards are built to celebrate.
Why does the stale-price arm matter if it is only ten minutes?
Because a cache lifetime is the upper bound on how long any change takes to take effect, including a revoked permission or a withdrawn price. Ten minutes is short enough that reports get dismissed as user error and long enough to matter for the changes you care about.
What is the recommended fix?
Exclude caching policy objects and the request properties a key function reads from member renaming, then assert the emitted Cache-Control and Vary headers on one authenticated route and add an end-to-end check that an unauthenticated request for the same path returns nothing from the authenticated response.
Related reading