PST-022: Expired and orphaned sessions are never purged, so held imports (parsed CVs, spreadsheets, vCards) persist and survive account deletion #751

Closed
opened 2026-10-10 10:56:00 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-022. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: data retention / erasure
  • Location: src/postulo/core/csv_import.py (csv_import.py:1262-1280); src/postulo/notifications/management/commands/send_due_reminders.py (send_due_reminders.py:176-230); src/postulo/core/views_import.py (63-71)
  • Found by: business logic and imports, completeness review: chains, completeness review: coverage
  • Related: PST-021 (#750)

Who can do it, and what they need. Not an active attacker: a privacy and erasure defect. It becomes exploitable for anyone later given a database copy or backup, or access to django_session (an administrator with SQL access, a leaked backup). It also affects people who delete their account.

How it works.

  1. A member uploads a CSV (up to 2 MB, one row per application with company, role, notes and salary) or a candidate/Europass file (name, address, phone numbers, date of birth) and abandons the review page, or signs in on a second device.
  2. The content stays in django_session.session_data. Postulo never calls clearsessions, and the session expiry only makes it unusable, not deleted.
  3. The member deletes their account. delete_account() cascades their rows but not sessions, which are not foreign-keyed to the user, and the 'everything in it has been deleted' message is shown. Only the current session is flushed by logout().
    Verified: after upload, expiring the session 30 days back, running tidy_up_if_due() and delete_account(user), the session row still held the spreadsheet.

Impact. Personal data, including other people's names and phone numbers from imported files, outlives its stated retention and the account holder's erasure, and travels into every backup. The data-protection pages and erasure promise do not mention it.

Evidence.

Scratch test output: 'expired sessions after Postulo's own tidy-up: 1', 'sessions left after account deletion: 1', 'rows still holding the person's spreadsheet: [True]'. `grep -rn clearsessions` over the repo returns nothing.

Verification (verdict: confirmed; severity after verification: Low). Files and lines are accurate. The attacker precondition is a database copy or backup taken after the erasure, or an administrator with SQL access; there is no remote attacker. The impact is an erasure and retention defect, not a confidentiality break for live accounts. A suggested fix is to run clear_expired (clearsessions) from the scheduler pass or tidy_up_if_due. A second change is to delete sessions that hold the user's id in delete_account, by scanning the decoded session data for _auth_user_id, or to use a user-keyed session store. A third is to pop the stash after a short time. The deletion page and the wiki should mention sessions.

Suggested remediation. Run clearsessions from the scheduler and tidy_up_if_due. In delete_account(), delete all Session rows whose decoded _auth_user_id equals the user's pk (or store the user id in a column and cascade). Keep imported sheets in an owner-scoped, expiring table instead of the session, and drop them on forget/finish.

Also covered by this issue.

  • No scheduled purge of expired or abandoned database sessions, so held imports (parsed CVs, spreadsheets, vCards) are kept indefinitely (src/postulo/notifications/management/commands/send_due_reminders.py (send_due_reminders.py:176-230); Low). Unbounded growth of django_session and retention of personal data (and third parties' contact data) well past the session's life, compounding the erasure gap above. Only the anonymous-traffic path is clean: anonymous requests were verified to create no session rows.
  • CSV import keeps the whole uploaded spreadsheet in the errand row until a worker runs; a queue that never runs leaves it there for up to seven days (src/postulo/core/views_import.py (63-71); Informational). Personal data held longer and in more places than the import needs (the session copy is forgotten at once, the errand copy is not). Bounded to the owner's own data and to operators with database access; no cross-account path. Informational.
Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-022**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** data retention / erasure - **Location:** `src/postulo/core/csv_import.py` (csv_import.py:1262-1280); `src/postulo/notifications/management/commands/send_due_reminders.py` (send_due_reminders.py:176-230); `src/postulo/core/views_import.py` (63-71) - **Found by:** business logic and imports, completeness review: chains, completeness review: coverage - **Related:** PST-021 (#750) **Who can do it, and what they need.** Not an active attacker: a privacy and erasure defect. It becomes exploitable for anyone later given a database copy or backup, or access to django_session (an administrator with SQL access, a leaked backup). It also affects people who delete their account. **How it works.** 1. A member uploads a CSV (up to 2 MB, one row per application with company, role, notes and salary) or a candidate/Europass file (name, address, phone numbers, date of birth) and abandons the review page, or signs in on a second device. 2. The content stays in django_session.session_data. Postulo never calls clearsessions, and the session expiry only makes it unusable, not deleted. 3. The member deletes their account. delete_account() cascades their rows but not sessions, which are not foreign-keyed to the user, and the 'everything in it has been deleted' message is shown. Only the current session is flushed by logout(). Verified: after upload, expiring the session 30 days back, running tidy_up_if_due() and delete_account(user), the session row still held the spreadsheet. **Impact.** Personal data, including other people's names and phone numbers from imported files, outlives its stated retention and the account holder's erasure, and travels into every backup. The data-protection pages and erasure promise do not mention it. **Evidence.** ```text Scratch test output: 'expired sessions after Postulo's own tidy-up: 1', 'sessions left after account deletion: 1', 'rows still holding the person's spreadsheet: [True]'. `grep -rn clearsessions` over the repo returns nothing. ``` **Verification** (verdict: confirmed; severity after verification: Low). Files and lines are accurate. The attacker precondition is a database copy or backup taken after the erasure, or an administrator with SQL access; there is no remote attacker. The impact is an erasure and retention defect, not a confidentiality break for live accounts. A suggested fix is to run clear_expired (clearsessions) from the scheduler pass or tidy_up_if_due. A second change is to delete sessions that hold the user's id in delete_account, by scanning the decoded session data for `_auth_user_id`, or to use a user-keyed session store. A third is to pop the stash after a short time. The deletion page and the wiki should mention sessions. **Suggested remediation.** Run clearsessions from the scheduler and tidy_up_if_due. In delete_account(), delete all Session rows whose decoded _auth_user_id equals the user's pk (or store the user id in a column and cascade). Keep imported sheets in an owner-scoped, expiring table instead of the session, and drop them on forget/finish. **Also covered by this issue.** - **No scheduled purge of expired or abandoned database sessions, so held imports (parsed CVs, spreadsheets, vCards) are kept indefinitely** (`src/postulo/notifications/management/commands/send_due_reminders.py` (send_due_reminders.py:176-230); Low). Unbounded growth of django_session and retention of personal data (and third parties' contact data) well past the session's life, compounding the erasure gap above. Only the anonymous-traffic path is clean: anonymous requests were verified to create no session rows. - **CSV import keeps the whole uploaded spreadsheet in the errand row until a worker runs; a queue that never runs leaves it there for up to seven days** (`src/postulo/core/views_import.py` (63-71); Informational). Personal data held longer and in more places than the import needs (the session copy is forgotten at once, the errand copy is not). Bounded to the owner's own data and to operators with database access; no cross-account path. Informational.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:31 +00:00
Author
Owner

Fixed in e385b1b96 on main.

Fixed in e385b1b96 on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#751
No description provided.