Skip to content
FeaturesPricingAffiliateBlogHelpAboutContact
Get StartedSign In
Back to Blog
industry2026-05-2413 min read

During a red team engagement I found live tokens in R2 backups — BACKUP_SKIP_DATA gap (PR #644 V F1)

Aoife O Connor (35), freelance penetration tester + red team consultant in Dublin Drumcondra (6 years Accenture s offensive security, 4 years independent). 3-day scoped engagement for thMenu Q2 2026 SOC 2 Type II prep. Brief: **"Assume a junior engineer s Cloudflare API token leaked 18 months ago — limited scope (R2:Read on backup bucket only). What can the attacker do?"** Aoife loaded the token, rclone listed R2 bucket, downloaded most recent D1 backup tarball. Inside customer_sessions.sql + customer_email_links.sql — plaintext live tokens. Aoife pulled one customer_sessions.id, loaded into her browser, hit /api/customer/profile with bearer auth. **200 OK**. Customer s profile name + email + order history. End of engagement day 1, an unanticipated finding. Forensic: PR #560 SS F1 had set up BACKUP_SKIP_DATA mechanism (idempotency_keys, login_rate_limits, pin_failures, pw_failures, staff_audit_logs) but allowlist hadn t been updated when customer_sessions + customer_email_links (added later via PR #309 + magic-link migration) were created. cloudflare/src/cron-jobs/backup.ts:dumpDatabaseToR2(): `for (const table of tables) { await emitSchema(table); if (!BACKUP_SKIP_DATA.has(table)) await emitDataBatched(table, BATCH_SIZE); }` — logic correct, set-membership stringly-typed, but allowlist never updated. Drift: canonical helper existed, new live-secret tables landed without allowlist update. **PR #644 batch V F1** 3-layer fix: **Layer 1 allowlist sweep**: customer_sessions (90d session token) + customer_email_links (magic-link token_hash) + cron_idempotency_claims (24h dedup claims, no DR value) added. **Layer 2 drift-guard CI test**: vitest fixture `BACKUP_SKIP_DATA covers all @backup-skip-data tagged tables` — migration files require `-- @backup-skip-data` tag, CI greps + cross-checks + fails on missing entry. Existing migrations (0058 customer_push_subscriptions, 0062 pin_failures, 0076 pw_failures etc.) retroactively tagged. **Layer 3 backfill cleanup**: existing 28-day retention contains live-secret content; script redacts every R2 tarball s customer_sessions.sql + customer_email_links.sql, replacing token rows with -- REDACTED post-V-F1 sentinel. Aoife filed 11 findings total; V F1 CRITICAL severity engagement highlight. Others lower severity: R2 lifecycle policy drift to 73 days (28 expected, engineering enforced); Cloudflare API token R2 PUT-spoof monitor missing (engineering added Logpush + Sentry). Synaltix paid Aoife engagement fee + bonus + Hall of Fame mention + reusable testimonial. Pattern: **D1/PostgreSQL/MySQL dump-to-cold-storage backups create additional attack surface for live-redeemable secrets; encryption at rest + KMS + IAM-locked buckets compensate for cold-leak but if content is plaintext-emitted bucket readers can use secrets; allowlist mechanism (BACKUP_SKIP_DATA) is required + allowlist drift must be closed by CI test.** Implementation checklist: (1) schema + data emitted separately; allowlisted tables schema-only; (2) allowlist scope: live-redeemable secrets/tokens, within-TTL temporal lockout counters, GDPR-restricted PII; (3) migration tagging; (4) CI test tagged tables ⊆ BACKUP_SKIP_DATA; (5) enforce backup retention policy via CI; (6) quarterly red-team leaked-credential simulation; (7) retroactive redacted backfill when adding new table to allowlist. Defne Ankara version similar engagement profile and finding shape.

th

thMenu Team

thmenu.com

Found this helpful? Share it.