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 private 0700 evidence directory with per-file SHA-256 parity, then deleted from wp/wp-content/plugins/quizwizz/. The public vhost’s droppings deny was widened from exact-extension to suffix-aware, nginx -t passed and nginx reloaded. All four sampled backup URLs now return 404; four artificial suffixed URLs return 404 at the nginx edge; wrdp-health.sh is 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)PathStatusContent-TypeBytes
includes/Admin/SettingsPage.php.bakloopback http://127.0.0.1:51080200application/x-trash29,522
includes/Admin/SettingsPage.php.bakpublic https://wrdp.loca.zone404text/html146
includes/Settings/SettingsSchema.php.bak-20260904-cutoverpublic200none17,979
includes/ExposureSettings.php.bak-20260904-cutoverpublic200none32,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 count 58 — 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 41 M (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), owner loca, mode 0700.
  • All 58 files copied with shutil.copy2 into files/<plugin-relative path>; every preserved copy re-hashed. Mismatches: 0 of 58 (failed-copy-check.txt was therefore never written).
  • Deletion: one sudo -n rm -- <absolute path> per inventoried file, each path asserted to contain .bak in 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 0 filenames containing .bak.
  • Manifest: evidence/2026-09-14-bak-exposure-cleanup/manifest.json (mode 0600), SHA-256 43a12f5e04663327619c8d79ee533cde3a2805460779c90fc4cc1e2c7f490cb5. It holds created_at_utc 2026-09-13T22:14:53+00:00, source_root, entry_count 58, 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.txt and url-proof-after.txt sit 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: exit 0 — 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: exit 0.

5. After

URL (plugin-relative unless noted)PathStatusBytesWhat it proves
includes/Admin/SettingsPage.php.bakloopback404314Apache 404 body — file absence (loopback has no nginx deny)
includes/Admin/SettingsPage.php.bakpublic404146nginx deny, as before
includes/Settings/SettingsSchema.php.bak-20260904-cutoverpublic404146nginx deny now catches the suffix (was 200 / 17,979)
includes/ExposureSettings.php.bak-20260904-cutoverpublic404146nginx deny now catches the suffix (was 200 / 32,392)
includes/NoSuchFile.php.bak-testpublic404146widened rule, no file involved
includes/NoSuchFile.php.old_foopublic404146widened rule, old family
includes/NoSuchFile.orig.copypublic404146widened rule, orig family
includes/NoSuchFile.save-1public404146widened rule, save family
quizwizz.php (control)public2000direct PHP still routed to Apache, unchanged behaviour
/ (control)public200124,667homepage unaffected
build/quiz/block.json (control)public200625ordinary 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, exit 0.
  • ./scripts/qwgit.sh status --short: 41 M entries, 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) and loca.zone.access.log (961,881 records). Requests for /wp-content/plugins/quizwizz/… names in the bak/old/orig/save families: 17, all from this host’s own public IP — 5 Ă— 200 and 12 Ă— 404. The five 200s 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 returned 404. 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 no Host, 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-ran nginx -t (exit 0), replayed the eleven-URL matrix with curl (identical statuses and byte counts), and re-ran qwgit.sh status --short (41 M, 0 ??) and wrdp-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 — the swp/swo family 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-only only — exit 0, 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.

URLBeforeAfterWhat it proves
loopback uploads/zz-edge-proof.txt.bak-test200 / 22200 / 22fixture exists and is served by Apache; loopback has no deny (boundary, unchanged)
public uploads/zz-edge-proof.txt.bak-test404 / 146404 / 146existing rule, unchanged
public uploads/zz-edge-proof.txt.bak-test/404 / 316404 / 146trailing slash escaped to Apache before; denied at the edge now (red → green)
public uploads/zz-edge-proof.txt.swp-1200 / 22404 / 146suffixed swap file was served raw before; denied now (red → green)
public includes/NoSuchFile.bak/404 / 316404 / 146path-info after a denied name, no file involved
public includes/NoSuchFile.php.swp-1404 / 316404 / 146swp family now suffix-aware
public / (control)200 / 124,667200 / 124,667unaffected
public build/quiz/block.json (control)200 / 625200 / 625unaffected
public /?rest_route=/ (control)—200 / 231,556WordPress REST still routed
public /wp-json/404 / 316404 / 316pre-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 / 146the §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.