1,402 Backup Files: Verify by Request, Not by Layout

1,402 Backup Files: Verify by Request, Not by Layout

BackerLeader ●1 ●3 ●24
calendar_today ago • schedule4 min read

This week I got roasted on r/PHP for my deploy workflow - self-deleting token-gated PHP workers, uploaded through cPanel, no git, no containers. One of the critiques in the thread was my habit of keeping .bak files next to the files they back up. Fair hit. The punchline: I already knew. A reviewer had flagged backups-in-webroot weeks earlier, I shipped an .htaccess rule denying access to them, moved on. And this morning a spot check found 40 of 40 sampled backups returning 200 over plain HTTP - and the guard that was supposed to stop it had silently vanished from .htaccess entirely. This is the story of the 1,402 files, why the protection died without telling me, and the fix that actually holds: a vault outside the docroot, a manifest, and a weekly cron that verifies by request.

The droppings accumulate

My host gives me a cPanel file manager, not a shell, so every change to my site ships as a token-gated PHP worker: it patches a file with needle guards, keeps a backup until I confirm the result, and self-reports. Multiply that by four months of daily workers and you get 1,402 files named like arcade.html.bak-rackbay2.bak. Each one was a deliberate decision - keep the backup until I confirm the patch looks right. None of them was a decision to publish. They just accumulated in the webroot, because that is where the workers ran.

The worst part: the .htaccess FilesMatch guard I shipped weeks ago was gone. Not weakened - absent. Some other edit or panel operation had rewritten .htaccess and my rule was no longer in it. I do not know when. That is the whole problem in one sentence: I could not have known when.

Verify by request, not by layout

The critique that changed my mind came from Mike Dabydeen, who reviewed my worker architecture: verify by request rather than by layout, because on a managed host the layout is not yours to reason about. His concrete proposal: each worker writes every path it creates to a manifest outside the docroot, and the verify step requests each path over HTTP and fails on anything but a 403 or 404. His sharpest warning: a managed host serving PHP behind Nginx ignores .htaccess entirely, so the FilesMatch deny that works today does nothing after a plan change or a migration.

He was right, and the r/PHP thread proved it twice over. First, the files were reachable: 40 of 40 sampled backups answered 200. Second, the guard was gone: the layout I thought I controlled had been rewritten underneath me. Configuration is a snapshot; requests are the ground truth. A file is only private if requesting it fails, and the only way to know is to request it.

The vault

The fix has three parts, all shipped in one worker:

  1. A vault outside the docroot. One level above public_html - a directory the web server can never serve because it is not in the document tree. All 1,402 files moved there in under a minute, mirroring their relative paths so nothing collides. Nothing was deleted; deleted backups cannot be restored.
  2. A manifest. Every move recorded to a JSON file in the vault: original path, new path, timestamp, and the HTTP status codes found during the check. The manifest only knows about files my workers made - which is exactly the boundary Mike drew, because it cannot claim authority over files it did not create.
  3. A weekly sweep. A CLI script, also in the vault and therefore unreachable by HTTP, running on cron: find every .bak, .old, .orig, and tilde-suffixed file in the docroot, request each one over HTTP, and log the ones that answer 200. A flag moves the live ones to the vault and says so - because an alert that just reports a count leaves you asking what to do at 6 a.m.

The numbers, and the cache trap

Post-move verification: ten moved paths re-requested over HTTP, ten 404s. The manifest has every original path, the vault has every byte, the webroot has none.

One honest footnote that proves the point about verification: right after the move, my own spot check still showed 200s - the host's page cache was serving the backups from memory. The files were gone from disk and still answering requests. A few minutes later the cache expired and the same URLs returned 404. If my verify step had trusted one request, I would have written down a false failure. Verify, then verify that the verification is the thing you think it is.

What changes in the template

The worker template now carries the lesson structurally: every path a worker creates or renames goes to the manifest, the verify step curls each one and fails the deploy on a 200, the backup policy is "outside the docroot, always," and the sweep cron exists even when I forget to. The threat is not the file I remember to protect; it is the file I forget I made.

Total cost: one directory, one JSON file, twenty lines of cron script, and a weekly line in the log. The alternative cost was 1,402 publicly readable files. The r/PHP thread caught what my own reviewer predicted - and both were cheaper than the breach that would have surfaced them.

Thanks to Mike Dabydeen for the review that predicted this failure mode before it happened. The manifest-and-request design in this post is his, and the fact that I found 40 of 40 files readable anyway is why "predicted by a reviewer" beats "fixed by an admin panel."


How do you handle deploy backups on managed hosting - sibling files, a vault directory, or do you just trust git? And has anyone else caught a security control that had silently stopped existing?

3 Comments

1 vote
0
0
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Comparison: Universal Import vs. Plaid/Yodlee

Pocket Portfolio - Mar 12

AI Email Classifier Cost per 1,000 Messages: Real Math

floxolab - Oct 8

The Interface of Uncertainty: Designing Human-in-the-Loop

Pocket Portfolio - Mar 10

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19
chevron_left
2.3k Points • 28 Badges
Rochelle Park, New Jersey, US • bryanhamilton.info
11Posts
12Comments
13Connections
Full-stack web engineer · PHP & vanilla JS · 25+ years · Founder of The DJ Calendar · Open to work

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!