Skip to content

[2.x] fix(gdpr): harden export download with audit trail, single-use, and active expiry check - #4642

Merged
imorland merged 1 commit into
2.xfrom
im/gdpr-export-download-hardening
May 8, 2026
Merged

[2.x] fix(gdpr): harden export download with audit trail, single-use, and active expiry check#4642
imorland merged 1 commit into
2.xfrom
im/gdpr-export-download-hardening

Conversation

@imorland

@imorland imorland commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

Hardens the GDPR data-export download flow in response to a security report flagging the GET /gdpr/export/{file} route as having no authorization (CWE-862) and no enforced session expiration (CWE-613).

The token-as-bearer model is preserved for now (the link still works without a session — that's a UX decision to revisit in a follow-up with a UI-driven download), but the practical exploit surface is closed:

  • Active expiry checkExport::byFile() now requires destroys_at > now(). Previously, expiry depended entirely on the daily gdpr:destroy-exports cron sweep, so a logically-expired token kept working until that ran (or indefinitely on instances where the scheduler isn't wired up).
  • Single-use — once an export is downloaded, the row is marked with downloaded_at and subsequent requests with the same token return 404.
  • Audit trail — successful downloads record downloaded_at, downloaded_ip, and downloaded_user_agent (truncated to 255 chars).
  • Two-stage lifecycle — the daily cleanup job now deletes the ZIP and nulls the file column rather than dropping the row outright. The audit row persists for usage analysis. The cron also picks up already-downloaded exports early, since their artifacts are no longer needed.
  • Better 404FileNotFoundException (which fell through to a 500) replaced with ModelNotFoundException so missing/expired/used tokens return a proper 404.
  • Defense in depthbyFile() rejects null/empty tokens and requires file IS NOT NULL, so a request with an empty ?file= cannot match a row whose token has been nulled by cleanup.

Schema

New migration adds three nullable columns to gdpr_exports:

  • downloaded_at (datetime)
  • downloaded_ip (varchar 45, IPv6-safe)
  • downloaded_user_agent (varchar 255)

Behaviour change for operators

Exporter::destroy() is renamed to Exporter::expire() and no longer deletes the row — it deletes the ZIP, nulls file, and marks notifications deleted. Audit rows accumulate over time but are cheap (one row per export request) and can be useful for understanding feature usage.

…ctive expiry check

- Active expiry check in Export::byFile() so logically-expired tokens
  stop working immediately, even if the daily cleanup cron has not run.
- Single-use enforcement via downloaded_at — second use returns 404.
- Audit metadata captured on download (timestamp, IP, UA truncated to
  255 chars).
- Two-stage cleanup: cron now deletes the ZIP and nulls the file
  column rather than removing the row, preserving the audit record.
  Already-downloaded exports get their artifact swept early.
- Replace FileNotFoundException (500) with ModelNotFoundException (404)
  so missing/expired/used tokens return a proper status.
- byFile() rejects null/empty tokens and requires file IS NOT NULL as
  defense in depth against requests against cleaned-up rows.

Exporter::destroy() is renamed to Exporter::expire() to reflect the
new semantics.
@imorland
imorland requested a review from a team as a code owner May 8, 2026 07:38
@imorland imorland added this to the 2.0.0-rc.2 milestone May 8, 2026
@imorland
imorland merged commit 3082f29 into 2.x May 8, 2026
25 checks passed
@imorland
imorland deleted the im/gdpr-export-download-hardening branch May 8, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant