The login check worked
At the application, the account page behaved as expected. A signed-in client received information from a controlled account. An anonymous client was denied on the canonical route. The private response also carried a restrictive caching instruction.
Yet the same account body later reached a fresh client with no session. An intermediary had treated another representation as public and stored it. The application's privacy instruction had not survived the delivery path.
That explains why the successful login check wasn't the end of the investigation. The handler could refuse anonymous requests and private content could still escape through a layer in front of it. Both observations were true.
The HTTP notebook
The striking part was the absence of an authenticated context on the later read. The notes described private controlled-account content returning anyway. This rebuilt exchange illustrates that observation without the route variation or cache-priming sequence.
Reconstructed for this article, not an original wire capture. The host uses the reserved .example namespace; paths, headers, and data are invented or redacted. Display only: no requests are sent.
The request has no session cookie. The body uses a made-up marker, not personal data. A successful response would only matter here if the content were independently established as private.
The disagreement sat between the handler and the client
The origin and the intermediary disagreed about what they were handling. One saw an account operation. The other applied a public caching classification, overriding the privacy instruction. Reviewing just the application code or just the cache configuration would miss the relationship between those decisions.
A shared cache can answer without running the handler at all. If it already holds private content as a public object, the login check may never get a chance to reject the request.
The repair therefore needs two separate checks: may this request use shared storage, and may this response enter it? Blocking new writes is not enough when old objects remain available. Lookup, storage, and route classification need to agree.
The same account body was the important evidence
A 200 response could be a public page or a sign-in screen. The useful evidence was the body: it matched private content from the controlled account and was delivered without a session. Together with the canonical route's denial, that supports an authentication-boundary disclosure.
It does not tell us how many accounts were affected, how long a cached object lived, or whether anyone accessed one before the research. Those require separate evidence. There is no basis here for claiming a historical breach or exposure across every private feature.
In a local regression suite, use independent client contexts and a harmless marker in a synthetic account. Compare that marker or a fingerprint of the fixture. The test doesn't need real account bodies or credentials in its output.
Make every cache layer respect the same rule
Start the proposed repair with a clear list of resources that are actually public. Use strict canonical routing and trusted route metadata shared with the intermediary. Private handlers bypass shared lookup and storage. If the classification is unknown or the layers disagree, bypass rather than guess.
The illustrative policy below checks before lookup and again before storage. The application owns the route registry and response classification; they are not claims accepted from the client. Each intermediary needs an equivalent rule.
# Synthetic policy for the repaired application.
lookup_policy(request, trusted_route):
if not trusted_route.is_canonical:
return BYPASS
if request.has_authentication_context:
return BYPASS
if trusted_route.kind != PUBLIC_STATIC_RESOURCE:
return BYPASS
return ALLOW_PUBLIC_LOOKUP
storage_policy(lookup_decision, response):
if lookup_decision != ALLOW_PUBLIC_LOOKUP:
return DO_NOT_STORE
if response.depends_on_identity:
return DO_NOT_STORE
if response.is_authentication_result:
return DO_NOT_STORE
if not response.explicitly_public:
return DO_NOT_STORE
return STORE_PUBLIC_RESOURCE
Then deal with what is already stored. Invalidate objects associated with private content and check the effective configuration, not just the header the application emits. Document how a routing change reaches each cache layer; otherwise, a later deployment can bring the disagreement back.
Check lookup and storage separately
Run these five local cases with in-process handlers, synthetic identities, and fake cache adapters. Assert which cache reads and writes happened, as well as what the caller received. No remote requests are needed.
| Case | Fixture and local action | Expected state |
|---|---|---|
| 1. Every hop agrees | Connect fake intermediary adapters to a private handler. Evaluate a synthetic authenticated call through the repaired pipeline. | Every adapter bypasses shared lookup and storage; no account body enters a shared store. |
| 2. Anonymous isolation | Place a harmless synthetic account object in a fake legacy cache. Evaluate the private handler with an independent anonymous context. | The object is never read or returned. The handler denies access and migration cleanup removes the obsolete entry. |
| 3. Representation consistency | Give the resolver a synthetic noncanonical route descriptor for a private handler. | The descriptor is rejected or remains private. It cannot receive a public caching classification. |
| 4. Authentication results | Evaluate synthetic sign-in, access-denial, and session-dependent response objects against the storage policy. | Each returns do-not-store, including when a surrounding fixture proposes public storage. |
| 5. Public resource control | Evaluate an allowlisted, identity-independent static fixture with an anonymous context. | Public lookup and storage work as intended; the body contains only the public fixture. |
The handler was only part of the answer
The application team and the infrastructure team need to be able to answer the same questions: what is public, what bypasses shared caching, and what happens to old objects when the policy changes? A header in the source code doesn't answer those questions for a deployed system. A verified repair would need evidence from that system; these notes do not establish one.
When a validation helper became a data query follows a different disagreement between layers: a small public form sitting in front of a much broader internal helper.
Does private content stay private at every layer?
Our web application penetration testing considers the delivery layers in your agreed scope, not just the application handler. Tell us about your application, CDN or cache setup, and target date.
Request a pentest