Everything below describes what was true in the days around 30–31 August 2026. Since then: the leaked files were removed and are now blocked at the edge by firewall rules, the admin query-string value has been rotated, and — the fix that actually mattered — the schema hole that let a client mark a business "active" without paying has been closed server-side and verified against production. The admin parameter is not a working bypass any more, on either count. This is a postmortem of a closed incident, not a live map of this system.
I was about to commit some strategy notes into a markdown file in one of my repos. Before doing it I stopped to check whether that file was reachable from the web, mostly out of habit.
$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
https://loyalty.tminuslabs.space/HANDOFF.md
200 150205
Two hundred, and a hundred and fifty kilobytes. That is my engineering handoff document — the running notes for the whole project — being served to anyone who asked for it.
What was in it
Nothing that qualifies as a credential. No API keys, no tokens, no customer data. That is the only reason this is a blog post rather than a much worse week.
What it did contain:
- An admin query-string parameter that skipped the payment step during onboarding, mentioned eleven times, each time described plainly as the path that skips checkout.
- A write-up of a known, unfixed hole in my own schema — a column that clients could set at insert time, letting anyone create a fully paid-looking account without paying. Written up as a reproduction, because it was a note to myself.
- Database identifiers, every table and function name, and the full history of which permissions had been tightened and when.
So: not a data breach. An instruction manual.
Why it was public
The static host publishes a build output directory. On this project that directory was the repository root. Every tracked file in the repo was therefore a public URL, and the handoff document lived in the repo because that is where documentation belongs.
The mental model failure is simple and, I suspect, common: I thought of the repo as "the project" and the site as "the pages in it." The host does not make that distinction. It uploads what is in the directory.
I assumed anything starting with a dot would be skipped. It is not. .claude/launch.json, an editor tool config, was served with a 200 — and it leaked a local filesystem path including my username. On a sibling project the same was true of .github/workflows/*.yml and .github/scripts/*.js.
Meanwhile .DS_Store was not served — and the reason is the whole mechanism in one line. It is in .gitignore, so it was never committed, so it was never uploaded. On this host, publishing serves precisely what is committed inside that directory and blocks nothing by filename. That is a finding about this specific platform, not a law of static hosting — some hosts (GitHub Pages, Netlify) already skip dotfiles by default. Check your own host's actual behaviour rather than assume either way.
The fix that didn't work, twice
Removing the files and redeploying was straightforward. Confirming it was not.
After the deploy, the file was still being served. I purged the CDN cache. Still served. I purged it again. Still served.
The headers explained it, once I read them properly instead of skimming:
cf-cache-status: DYNAMIC
age: 67735
cache-control: public, s-maxage=604800
cf-cache-status: DYNAMIC means the platform's zone-level cache did not serve this from cache at all — a "purge everything" against that layer was never going to touch it, because it was never there. The age and s-maxage=604800 belong to a different cache layer entirely: the static host's own asset/edge tier, which sits in front of the zone cache and which a zone purge does not reach. age climbed steadily across every check — 4,396, then 10,139, then 67,735 — tracking wall-clock time, which is exactly what you'd expect from an object a purge never touched, rather than a purge that ran and failed.
The clincher was comparing two hostnames that serve the same deployment. The platform's own default domain returned the clean current version. My custom domain returned the old file. Same underlying deployment, different answers — that per-hostname split, not the cache-status header on its own, is the real evidence for where the stale copy actually lived. Read on the header alone, this looks like "Cloudflare ignored my purge." It didn't ignore anything — the purge tool and the layer holding the stale copy were two different systems.
The diagnostic that separates the two cases
"The deploy failed" and "the deploy worked and the cache is stale" look identical from outside. Here is how to tell.
Cache keys include the full URL, query string and all. So a URL that has never been requested cannot be in any cache, and must go to the origin:
curl -s -o /dev/null -w "%{size_download}\n" \
"https://your-site.com/notes.md?cb=$RANDOM"
If that returns your 404 fallback while the plain URL returns the file, your deploy was fine and you have a caching problem. If both return the file, the deploy did not do what you thought.
Having discovered that trick, I then used it to verify the fix. It told me the origin was clean, I declared the problem solved, and moved on. Twice.
But a cache-busted request answers "is the origin clean," which is a different question from "can a visitor still fetch this." A real visitor sends the plain URL and hits the cache. My verification was measuring the one thing that was never in doubt.
Verify exposure with a plain request. Use cache-busting to diagnose, never to confirm a fix.
What actually closed it
A firewall rule scoped to the affected hostname, matching a couple of path tokens unique to the leaked files. Those run at the edge before cache is consulted, so they work regardless of what is cached where, and they take effect in seconds rather than waiting out a seven-day TTL:
(http.host eq "your-production-host" and (
http.request.uri.path contains "<leaked-doc-token>"
or http.request.uri.path contains "/.claude/"
or http.request.uri.path contains "<tooling-file-token>"
)) → Block
One detail cost me an extra round trip. My first attempt matched a token that included a literal space. The path is percent-encoded on the wire — a space becomes %20 — so it never matched. Match on a token with no spaces in it.
The part that took longest to admit
Having closed the exposure, the obvious next move was to rotate the admin string the document had revealed. So I did.
Then I checked the published page it lived in:
$ curl -s https://loyalty.tminuslabs.space/onboarding | grep -c "<the old value>"
7
(Redacted here on purpose — the actual digits don't matter, and reproducing a real bypass value verbatim in a public write-up is exactly the kind of detail worth not repeating even after it's dead.) Seven hits, in a file the whole world can read. The check was client-side. The value had always been in the page source. The leaked document was never the exposure — view-source was, and it always would be, for whatever value I put there.
So the rotation bought almost nothing. It invalidated the string written into some stale cached copies, and that is genuinely all. The real fix was the server-side hole the document described: a column the client should never have been able to set. Closing that made the admin parameter stop being a way in, regardless of who knows it.
When a secret leaks, the instinct is to rotate it. Worth asking first whether the thing that leaked was ever a secret. If it lives in client-side code, it was public before the leak and will be public after the rotation, and rotating it mainly buys you the feeling of having acted.
A short checklist
- Fetch your own repo's non-page files against your production domain.
README.md,.env,.git/config, source maps, editor config, CI files. Use plain requests. - Check whether your build output directory is your repo root. If it is, everything committed is public — including dot-directories.
- Give the repo somewhere private. Move published files into a subdirectory and point the host at that, so the root can hold notes safely.
- Add an ignore rule so notes and tooling cannot land back in the published directory.
- After removing something, verify with a plain request, and check whether
ageresets. - For anything already cached, use an edge firewall rule rather than waiting on a purge.
- Ask whether the leaked thing was ever actually secret. If not, fix the mechanism instead of the value.
What I'd tell myself
The exposure existed for weeks and nothing alerted me. It was found because I happened to check something adjacent, on a whim. That is not a process.
The two purges that silently did nothing bothered me more than the original mistake. The mistake was a wrong mental model, which is ordinary. The failed verification was me measuring the wrong thing and believing the result — twice — which is the part worth building a habit against.
