A passive Chrome extension that flags potential web cache deception signals while you browse — no active exploitation, just observation of response headers on requests that carry a session cookie.
Built by CodeWithSamzy.
Install: download the latest release and follow the install steps.
These are real captures, not mockups: the extension loaded in headless
Chrome, rendering findings the service worker actually produced. The site is
the fixture server in test/e2e/fixtures.js, so nothing here came from a live
target. npm run screenshots regenerates them.
A request is flagged when all of these are true:
- The request carried a real session cookie. Requests whose only cookies are
known analytics or bot cookies (
_ga,_fbp,__cf_bm, ...) do not count, since they are not authenticated traffic. - The URL looks like a static asset (
.css,.js,.png, ...) or carries a delimiter that makes caches and origins disagree about it (%2e%2e,%2f%2f,%00,%23,%3f, or a;path parameter -- the benign;jsessionid=form is excluded). Only the path is examined, so a%2f%2for a.jsinside a query value is treated as ordinary data. - The response contradicts the URL: it comes back as a document or data
payload (
text/html,text/plain, JSON, XML) instead of the asset type the URL claims. This is the part that matters./static/app.jsanswering with JavaScript is an ordinary asset and is never flagged, while/my-account/x.jsanswering with HTML is the real cache deception shape. The comparison is real:/api/user.jsonanswering with JSON, and a.js.mapanswering with JSON, are what those URLs promise and are not flagged. - The response carries cache-related headers (
Cache-Controlwithoutno-store/private,Age,X-Cache, orCF-Cache-Status). - The response does not vary on credentials. A
VarynamingCookieorAuthorization(orVary: *) means the cache keeps a separate entry per user, so there is nothing for it to hand to the next visitor.
This is a signal, not proof -- always verify manually (e.g. with curl or Burp) before treating a flagged request as a real vulnerability.
Cache Sentry is not in the Chrome Web Store yet, so installing it takes one manual step. There is nothing to build: Chrome runs the source files as they are, so nobody needs Node or npm to install it.
- Open the Releases page
and download
cache-sentry-<version>-unpacked.zipfrom the newest release - Unzip it. You get a single folder named
cache-sentry. Keep it somewhere permanent -- Chrome reads that folder every time it starts, and the extension stops working if the folder is moved or deleted - Open
chrome://extensionsin Chrome - Turn on Developer mode (top right)
- Click Load unpacked and select the
cache-sentryfolder - The icon appears beside the address bar. Use the puzzle-piece menu to pin Cache Sentry so the badge count is visible
Chrome shows a developer-mode notice for any extension installed this way and may warn that it is not from the store. That warning is expected here; it is not a sign of a problem with the extension.
To update, download the newer zip, replace the folder, and press the reload
arrow on the extension card in chrome://extensions. There are no automatic
updates without a store listing.
git clone https://github.com/CodeWithSamzy/Cache-sentry.git, then follow
steps 3-6 above and point Load unpacked at the repository folder. Chrome
ignores the files it does not need (test/, docs/, tools/, dist/).
Edge, Brave, Vivaldi and Opera load the same folder from their own extensions
page (edge://extensions, brave://extensions, and so on). Edge Add-ons also
accepts the same package if this is ever published there.
- Browse normally, or navigate to a site you're authorized to test
- If a suspicious request/response is seen, the toolbar icon shows a badge count
- Click the icon to see each flagged URL, what the response did to
contradict it (
a text/html response came back for a .js URL), and which cache headers were present. Repeat hits on the same URL collapse into one row with a ×N count, and the badge counts distinct URLs rather than hits - Findings reset when you navigate to a new page (per-tab)
- Check Page loads to see what the cache did with the documents this tab fetched, including plain directory paths the detector leaves alone
- Click Export findings (JSON) to save this tab's findings as a report you can attach to a ticket or a write-up
Findings are held in chrome.storage.session, so they survive the extension's
service worker being shut down (Chrome stops it after ~30 seconds idle) and are
cleared when the browser closes. The rejected-request log described below is
not persisted: it is a debugging aid, not a finding.
"Nothing showed up" is ambiguous. Either no suspicious-looking URL was seen at all, or one was seen and a check threw it away. The popup's Why nothing flagged? section lists the requests that looked suspicious and were then dropped, with the reason:
| Reason | What it means |
|---|---|
no-session-cookie |
the request sent no session cookie, so there was nothing private to leak |
not-document |
the response really was the asset type the URL claimed (e.g. text/javascript) |
no-body |
the response has no body at all (a 304 revalidation), so its Content-Type means nothing |
matches-url |
the response type is what the URL already claims, so nothing is contradicted |
varies-on-credentials |
the response varies on Cookie/Authorization, so each user gets their own copy |
not-cacheable |
no shared cache would keep it (no-store, private, or no cache headers at all) |
So if you load a URL that should be vulnerable and get nothing, open this section first. If the URL is not in that list either, then no request carrying a session cookie was made for it in that tab.
The detector only analyses URLs that look like static files, so a static
directory path (/resources/anything, no file extension) is invisible to it
even while a cache is storing it. The popup's Page loads section covers that
gap, because it records every main document this tab fetched rather than only
the suspicious-looking ones.
| What the readout shows | What it means |
|---|---|
no cache hit |
no Age or hit marker, so this load was not served from a shared cache |
from cache with age: 7 · x-cache: HIT |
a shared cache served this response |
Load a URL, then load it again. Both entries stay in the list, so a first visit that misses and a second that hits is visible as a change between them. That is the passive answer to "does this URL get cached?" -- it reports requests Chrome already made and never sends one of its own.
If Chrome's own HTTP cache answers instead of the network, no new entry is recorded. Change the path or add a query string to force a real fetch.
PortSwigger's Web Security Academy labs on Web Cache Deception are a safe,
legal place to test this — load a lab, log in, and browse to a path like
/my-account/nonexistent.js to see if it gets flagged.
npm test # unit suite, no browser required
npm run test:e2e # browser suite, needs Chrome
npm run test:all # both
npm run package # builds the two dist/*.zip archives
npm run screenshots # re-captures docs/screenshots/ in Chrome
npm run package writes both distribution archives. The file list is
derived from manifest.json and popup.html, so an asset that is referenced
but missing fails the build instead of shipping a broken extension. Each
archive is unpacked and checksummed as it is written:
| Archive | Shape | Used for |
|---|---|---|
dist/cache-sentry-<version>.zip |
manifest.json at the archive root |
uploading to a store |
dist/cache-sentry-<version>-unpacked.zip |
one cache-sentry/ folder |
the manual install above |
npm run screenshots drives a real headless Chrome with the extension
loaded and captures the popup, so the images in this README cannot drift from
what the extension actually renders. It writes into docs/screenshots/.
**`npm test`** loads `background.js` in a Node VM with a stubbed `chrome` API
and drives real request/response pairs through it. It covers the happy path,
every gate that suppresses a finding, per-tab isolation and cleanup, the
persistence behaviour, and the known false positives written as explicit tests
so heuristic changes surface as failures instead of silent drift.
**`npm run test:e2e`** drives a real headless Chrome, because some things a stub
cannot reproduce:
- the order Chrome actually delivers `webNavigation` and `webRequest` events,
which is what made a blanket reset on navigation erase the finding for the
very page being loaded
- the real service worker lifecycle, so persistence is proven by destroying the
worker and reviving it rather than by trusting that a storage call happened
- the popup's rendered DOM, including that a header value cannot inject markup
- the diagnostic log: a known-safe asset has to show up as `not-document`, both
in the worker's state and in the popup, and a hostile value must not become
markup
- the page-load readout against a real static-directory cache rule: the first
load misses, the reload is reported as `from cache`, and the URL is correctly
*not* turned into a finding
It starts a fixture server on an ephemeral port, launches Chrome with a
throwaway profile, and loads the extension over CDP. Set `CHROME_PATH` if Chrome
is not in a standard location; the suite prints a SKIP message if it cannot find
one.
Chrome 137+ branded builds ignore `--load-extension` and
`--disable-extensions-except`, so the extension is loaded with
`Extensions.loadUnpacked`, which requires `--enable-unsafe-extension-debugging`.
## Known limitations
- Trusts `Vary` as implemented. A response varying on `Cookie` or
`Authorization` is treated as not exploitable, which is correct for a cache
that honours it -- but caches that ignore or mishandle `Vary` do exist, and
those are not flagged.
- A response with no `Content-Type`, or one sent as
`application/octet-stream`, is not treated as a contradiction, so a
genuinely mislabelled asset is missed rather than guessed at.
- A static directory path with no file extension is never turned into a finding,
even when a cache is storing it. **Page loads** shows it instead, which keeps
the finding list clear of the false positives that shape would otherwise
produce.
- A cached JSON API is out of scope by design. Once the URL and the response
type agree (`.json` answering with JSON) there is nothing to contradict, and
headers alone cannot tell a public config endpoint from a private one. Such a
response only shows up in **Page loads**.
- Everything here is a heuristic over response headers. It is checked against a
fixture server and the unit suite, but not yet against a live vulnerable
target, so treat the first real-world run as a calibration exercise.
## Deliberately not built
- A "test the variants for me" button. Firing delimiter-trick requests from the
extension would turn observing traffic into probing a target, and it can cache
the user's own authenticated page in a shared cache -- the exact harm this
tool exists to report. The tool stays passive on purpose.
## Next steps
- Validate against a PortSwigger Web Cache Deception lab, and write down what
the real headers look like so the heuristics can be tuned to them
- Turn the readout into a verdict: when a URL that `Vary`s on a credential is
still served `from cache`, say so outright instead of leaving it to be read
- Compose 1280x800 listing screenshots. `npm run screenshots` already produces
the popup captures the README uses, but the store wants 1280x800 or 640x400
images, so those files need the popup put on a browser-sized canvas
- Publish to the Chrome Web Store (one-time 5 USD developer fee) or Edge
Add-ons (free, same package) so installing becomes a single click instead of
a manual load
## Privacy
No data leaves the browser. Findings live in `chrome.storage.session` and are
gone when the browser closes. `PRIVACY.md` has the full policy.
## License
MIT -- see `LICENSE`.


