Backup exposure cleanup
Dated bench cleanup, executed 2026-09-13 22:13–22:16 UTC (host-local 2026-09-14). It removes editor backup droppings from the web-served QuizWizz plugin tree and closes the nginx suffix bypass that served some of them. It changes no plugin source, no tracked file, no database row and no settings. It does not make the Sep-8 mirror commit safe, does not certify the 19-file packet against canonical HEAD, and is not release, publication or human acceptance. releaseEligible: false.
In one breath
58 untracked backup artifacts (
32 × .bak,26 × .bak-20260904-cutover) were inventoried, copied to a private0700evidence directory with per-file SHA-256 parity, then deleted fromwp/wp-content/plugins/quizwizz/. The public vhost’s droppings deny was widened from exact-extension to suffix-aware,nginx -tpassed and nginx reloaded. All four sampled backup URLs now return404; four artificial suffixed URLs return404at the nginx edge;wrdp-health.shis 7/7; the mirror shows zero untracked entries and the inherited 41 tracked-modified Sep-8 files unchanged.
1. What was exposed
Before any change, read-only probes (status, content-type and byte count only; no source content is reproduced here):
| URL (plugin-relative) | Path | Status | Content-Type | Bytes |
|---|---|---|---|---|
includes/Admin/SettingsPage.php.bak | loopback http://127.0.0.1:51080 | 200 | application/x-trash | 29,522 |
includes/Admin/SettingsPage.php.bak | public https://wrdp.loca.zone | 404 | text/html | 146 |
includes/Settings/SettingsSchema.php.bak-20260904-cutover | public | 200 | none | 17,979 |
includes/ExposureSettings.php.bak-20260904-cutover | public | 200 | none | 32,392 |
Cause: /etc/nginx/sites-available/wrdp.loca.zone:12-14 denied only names whose final extension is exactly .bak, .old, .orig or .save, so a suffixed name like .bak-20260904-cutover passed through to Apache, which serves unknown extensions as raw bytes. The loopback Apache path has no such deny at all, so plain .bak was raw there.
2. Inventory frozen before deletion
Walk of wp/wp-content/plugins/quizwizz/** for filenames containing .bak (Python os.walk, SHA-256 per file), written to /tmp/qwfix/bak-exposure-cleanup-20260914/inventory-before.tsv and copied into the evidence directory:
- expected count
58, observed count58— no drift since the plan was written - suffix split:
.bak= 32,.bak-20260904-cutover= 26, other = 0 - top-level split:
assets= 10,config= 2,includes= 12,languages= 34 - every file
www-data:www-data 0644, none a symlink - the 58 inventory paths are exactly the 58
??(untracked) entries of./scripts/qwgit.sh status --short; the 41M(tracked-modified) entries are disjoint and were not touched
The 14 config/ and includes/ entries, for orientation: config/settings-tiers.json{.bak,.bak-20260904-cutover}, includes/Admin/SettingsPage.php{.bak,.bak-20260904-cutover}, includes/Admin/views/settings-page.php{.bak,.bak-20260904-cutover}, includes/ExposureSettings.php.bak-20260904-cutover, includes/Frontend/Craft/CraftService.php.bak-20260904-cutover, includes/Frontend/Shortcodes/CraftShortcode.php.bak, includes/Frontend/views/builder-view.php.bak, includes/Frontend/views/finetune-editor.php.bak, includes/PresentationDocument.php.bak-20260904-cutover, includes/Settings/SettingsSchema.php{.bak,.bak-20260904-cutover}. The full list with hashes is in the manifest.
These plugin-tree .bak files are not authoritative before-images for anything; the Sep-8 dirty-baseline before-images live under evidence/2026-09-08-live-punchlist/ as recorded in the Sep-8 live punchlist receipt.
3. Preserve, verify, delete
- Private preservation directory:
evidence/2026-09-14-bak-exposure-cleanup/(repo-relative, outside the WordPress document root, not served by nginx), ownerloca, mode0700. - All 58 files copied with
shutil.copy2intofiles/<plugin-relative path>; every preserved copy re-hashed. Mismatches:0of 58 (failed-copy-check.txtwas therefore never written). - Deletion: one
sudo -n rm -- <absolute path>per inventoried file, each path asserted to contain.bakin its basename and to resolve inside the plugin tree. Result:removed= 58,already_absent= 0, failed = 0. No chmod/chown on the live tree; no directories removed. - Post-check: the same walk returns
0filenames containing.bak. - Manifest:
evidence/2026-09-14-bak-exposure-cleanup/manifest.json(mode0600), SHA-25643a12f5e04663327619c8d79ee533cde3a2805460779c90fc4cc1e2c7f490cb5. It holdscreated_at_utc2026-09-13T22:14:53+00:00,source_root,entry_count58, per-entry path/preserved path/size/mode/owner/group/mtime/SHA-256/preserved SHA-256/removal result, the pre and post URL probe rows as data fields, and the nginx step result.inventory-before.tsv,url-proof-before.txtandurl-proof-after.txtsit beside it.
4. Ingress hardening
/etc/nginx/sites-available/wrdp.loca.zone was staged as a copy under /tmp/qwfix/bak-exposure-cleanup-20260914/ (original kept as wrdp.loca.zone.orig) and installed root-owned 0644 with sudo -n install; it was not edited in place. Lines 12–14 changed from
location ~* \.(log|sql|bak|old|orig|save|swp|swo|tar|tgz|gz|zip|7z)$ { return 404; }to
location ~* \.(log|sql|bak(?:[-_.][^/]*)?|old(?:[-_.][^/]*)?|orig(?:[-_.][^/]*)?|save(?:[-_.][^/]*)?|swp|swo|tar|tgz|gz|zip|7z)$ { return 404; }sudo -n nginx -t: exit0—syntax is ok/test is successful. One pre-existing, unrelated warning was printed:"ssl_stapling" ignored, no OCSP responder URL in the certificate "/etc/letsencrypt/live/flxr.loca.zone/fullchain.pem".sudo -n systemctl reload nginx: exit0.
5. After
| URL (plugin-relative unless noted) | Path | Status | Bytes | What it proves |
|---|---|---|---|---|
includes/Admin/SettingsPage.php.bak | loopback | 404 | 314 | Apache 404 body — file absence (loopback has no nginx deny) |
includes/Admin/SettingsPage.php.bak | public | 404 | 146 | nginx deny, as before |
includes/Settings/SettingsSchema.php.bak-20260904-cutover | public | 404 | 146 | nginx deny now catches the suffix (was 200 / 17,979) |
includes/ExposureSettings.php.bak-20260904-cutover | public | 404 | 146 | nginx deny now catches the suffix (was 200 / 32,392) |
includes/NoSuchFile.php.bak-test | public | 404 | 146 | widened rule, no file involved |
includes/NoSuchFile.php.old_foo | public | 404 | 146 | widened rule, old family |
includes/NoSuchFile.orig.copy | public | 404 | 146 | widened rule, orig family |
includes/NoSuchFile.save-1 | public | 404 | 146 | widened rule, save family |
quizwizz.php (control) | public | 200 | 0 | direct PHP still routed to Apache, unchanged behaviour |
/ (control) | public | 200 | 124,667 | homepage unaffected |
build/quiz/block.json (control) | public | 200 | 625 | ordinary static asset unaffected |
The 146-byte body is nginx’s own 404 page; the 314-byte body is Apache’s. That distinction is how the table separates “denied at the edge” from “file gone”.
Runtime and mirror state after cleanup and reload:
./scripts/wrdp-health.sh:ok 1–ok 7, exit0../scripts/qwgit.sh status --short: 41Mentries, 0??entries. The 41 tracked-modified files are the inherited Sep-8 dirty-baseline state and are reported here as unchanged, not fixed; the Sep-8 mirror commit stays NOT PERFORMED / blocked exactly as the handover says.- No probe wave or browser gate was run: nothing under test changed, and the plugin source bytes are identical before and after.
6. Boundaries
- Fixed: web-served backup droppings and the suffix bypass on the public vhost.
- Not fixed and not claimed: loopback Apache still serves any raw file that exists under the document root, so the guard against future droppings is the stage-and-install protocol plus the untracked-file check in
qwgit.sh status, not the container. - Was anything fetched before the cleanup? Settled as a bounded negative by an independent read-only log sweep the same evening (statuses and origins only). Retained nginx logs are unrotated and cover 2025-11-13 06:53:55 to 2026-09-13 22:28:51 UTC:
/var/log/nginx/access.log(5,399,778 records) andloca.zone.access.log(961,881 records). Requests for/wp-content/plugins/quizwizz/…names in thebak/old/orig/savefamilies: 17, all from this host’s own public IP — 5 ×200and 12 ×404. The five200s are this session’s investigation probes at 22:03:13, 22:11:10 and 22:13:43 UTC (curl/8.14.1,bak-cleanup-probe); the two pre-window hits are host-origin curl probes on Sep-9 that returned404. The container’s Apache stdout (last 200,000 lines, 2026-09-02 14:06 to 2026-09-13 22:29 UTC) adds 10 matches, all the same self-probes from the host or the compose bridge. So: no third-party fetch of any backup dropping is recorded within the retention window. This is not “never fetched”: the combined log format carries noHost, encoded-path variants were not enumerated, and nothing before 2025-11-13 exists. - Independent replay: a separate read-only agent re-walked the plugin tree (
count 0), re-hashed all 58 preserved files against the manifest (0 mismatches, 0 missing, 0 size mismatches, no symlinks), byte-compared vhost line 12 against the planned regex (True), re-rannginx -t(exit 0), replayed the eleven-URL matrix withcurl(identical statuses and byte counts), and re-ranqwgit.sh status --short(41M, 0??) andwrdp-health.sh(7/7). Nothing refuted. - Edges of the widened rule, measured beyond the plan:
NoSuchFile.PHP.BAK-x→404/146 B (case-insensitive deny holds);NoSuchFile.php.bak-test?x=1→404/146 B (query string does not bypass);NoSuchFile.php.swp-1→404/316 B (Apache absence — theswp/swofamily was deliberately left exact-extension);NoSuchFile.bak/→404/316 B (a trailing slash escapes the$-anchored deny and reaches Apache; that is absence, not denial, and behaviour for an existing file plus path-info was not tested). None of these is a served dropping today because none exists in the tree. - Vault: validated with
/home/loca/dev/wikis/build.sh wrdp --check-onlyonly — exit0, SSOT validation passed, 90 Markdown files parsed, 949 files emitted to the scratch dist, the usual no-git warning. No production build.
7. Deny-edge hardening (same day)
§6 recorded two measured edges of the widened rule that were absence, not denial: a trailing slash after a denied name (NoSuchFile.bak/) reached Apache, and the swp/swo family was exact-extension only (NoSuchFile.php.swp-1 reached Apache). Both were closed the same day and mutation-proved on a throwaway 22-byte fixture (edge-proof 2026-09-14) installed twice into the runtime uploads directory (wp/wp-content/uploads/zz-edge-proof.txt.bak-test, …swp-1; sudo -n install -o www-data -g www-data -m 0644; runtime data, not source, not mirrored) so that the gate had something real to deny.
Line 12 of /etc/nginx/sites-available/wrdp.loca.zone changed from
location ~* \.(log|sql|bak(?:[-_.][^/]*)?|old(?:[-_.][^/]*)?|orig(?:[-_.][^/]*)?|save(?:[-_.][^/]*)?|swp|swo|tar|tgz|gz|zip|7z)$ { return 404; }to
location ~* \.(log|sql|bak(?:[-_.][^/]*)?|old(?:[-_.][^/]*)?|orig(?:[-_.][^/]*)?|save(?:[-_.][^/]*)?|swp(?:[-_.][^/]*)?|swo(?:[-_.][^/]*)?|tar|tgz|gz|zip|7z)(?:/.*)?$ { return 404; }The old line occurred exactly once (asserted before replacement); lines 13–14 are unchanged. Baseline sudo -n nginx -t exit 0 before the edit; after sudo -n install -o root -g root -m 0644 of the new file, nginx -t exit 0 and sudo -n systemctl reload nginx exit 0.
| URL | Before | After | What it proves |
|---|---|---|---|
loopback uploads/zz-edge-proof.txt.bak-test | 200 / 22 | 200 / 22 | fixture exists and is served by Apache; loopback has no deny (boundary, unchanged) |
public uploads/zz-edge-proof.txt.bak-test | 404 / 146 | 404 / 146 | existing rule, unchanged |
public uploads/zz-edge-proof.txt.bak-test/ | 404 / 316 | 404 / 146 | trailing slash escaped to Apache before; denied at the edge now (red → green) |
public uploads/zz-edge-proof.txt.swp-1 | 200 / 22 | 404 / 146 | suffixed swap file was served raw before; denied now (red → green) |
public includes/NoSuchFile.bak/ | 404 / 316 | 404 / 146 | path-info after a denied name, no file involved |
public includes/NoSuchFile.php.swp-1 | 404 / 316 | 404 / 146 | swp family now suffix-aware |
public / (control) | 200 / 124,667 | 200 / 124,667 | unaffected |
public build/quiz/block.json (control) | 200 / 625 | 200 / 625 | unaffected |
public /?rest_route=/ (control) | — | 200 / 231,556 | WordPress REST still routed |
public /wp-json/ | 404 / 316 | 404 / 316 | pre-existing Apache 404 on both sides (plain permalinks); not touched by this rule |
public includes/QuizService.php.bak, ….bak-20260901, assets/css/qwizz-builder.css.orig, includes/Plugin.php.save | (§5: 404 / 146) | 404 / 146 | the §5 adversarial variants still denied |
Fixture removal: sudo -n rm of both files, exit 0; both loopback URLs then return 404. ./scripts/qwgit.sh status --short still 41 M, 0 ??; ./scripts/wrdp-health.sh ok 1–ok 7, exit 0. Raw probe output (before.txt, after.txt, removed.txt) and both vhost images (wrdp.loca.zone.orig, .new) are preserved under evidence/2026-09-14-bak-exposure-cleanup/deny-edge/. Still not fixed and not claimed: the loopback caveat in §6.