PST-021: Account deletion leaves the person's email address, notes and held import data behind #750

Closed
opened 2026-10-10 10:55:59 +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-021. 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: privacy / erasure completeness
  • Location: src/postulo/accounts/deletion.py (deletion.py:91-139)
  • Found by: completeness review: coverage
  • Related: PST-022 (#751)

Who can do it, and what they need. No attacker is needed: the data subject who asked for erasure, a regulator, or anybody who later reads the database, an administrator's Invitations page, or a backup. The precondition is simply that the person was invited, or left a spreadsheet/vCard/candidate file waiting for review on a second device, before the account was deleted (by themselves, by an administrator via People > Delete, or by manage.py delete_account).

How it works.

  1. An administrator issues an invitation bound to maria@private.example with the note 'Maria from the bootcamp'; Maria accepts it (Invite.accept sets accepted_by).
  2. On another browser/device Maria uploads a CSV or a vCard for review; the parsed content is held in her server-side session row (keys csv_import / vcard_file / kind_file_* / candidate).
  3. The account is deleted. delete_account() removes pending invitations created by the user and calls user.delete(); Invite.accepted_by and created_by are SET_NULL, and django_session has no relation to the user.
  4. Verified in a scratch test: the Invite row still reads 'maria@private.example | Maria from the bootcamp' with accepted_by=None, and the same holds for an expired invitation created by a deleted administrator ('joe@private.example | Joe, referred by Sam', created_by=None). The session row still holds the spreadsheet (company 'SecretCorp Intl') and the vCard (name, email and telephone of a third party) after delete_account(): 'sessions before/after delete: 1 1, held personal data present before: True after: True'.
  5. Every administrator keeps seeing the erased person's address in Server settings > Invitations ('For' column, status Accepted).

Impact. The 'Your account and everything in it have been deleted' promise is false for the erased person's email address and free-text note (kept in invitation rows visible to every administrator), for anything they had waiting in a session (their own CV/spreadsheet and third parties' contact details from a vCard), and for copies already pushed to external stores (Paperless, WebDAV) because Postulo never calls a store's delete. All of it also rides along in every backup. …

Evidence.

deletion.py:108-112: `report.rows["pending invitations revoked"] = (Invite.objects.filter(created_by=user).pending().delete()[0])` ... `_count, per_model = user.delete()`. models.py:746-752: `accepted_by = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, ...)`; 725/730: `email = models.EmailField(...)`, `note = models.CharField(...)`. Scratch output: 'INV accepted-by-deleted: maria@private.example | Maria from the bootcamp | accepted_by: None | created_by: 2' and 'SDEL session keys after: [[_auth_user_id, _auth_user_backend, _auth_user_hash, csv_import, vcard_file]]'.

Verification (verdict: confirmed; severity after verification: Low). Keep it low. Cite deletion.py:108-112 and models.py:736/748. The user-visible overclaim is views.py:545 ('everything in it') and server_views.py:331-340, not the delete page's invitation list, which is accurate. The main leftovers are Invite.email and Invite.note for accepted invitations and for invitations created by a deleted administrator. The session leftover is secondary. Self-service deletion does flush the current browser's session, and the stale rows are inert as credentials because _auth_user_id points at a deleted user. They still hold the stashed spreadsheet or vCard, and nothing ever prunes expired session rows (no clearsessions). That is a related retention weakness even without account deletion. Drop the external-store point or mark it informational, since those copies are in services the person controls. The Invitations page is visible only to administrators who typed the address themselves. Remediation: null or delete Invite.email and Invite.note when accepted_by or created_by is erased, or delete accepted invitations bound to the deleted account's address. …

Suggested remediation. In delete_account: delete (or blank email and note on) every Invite whose accepted_by is the user or whose email matches an address the user held, not only pending ones the user created; delete the person's django_session rows (record the user pk in a column or iterate sessions and match _auth_user_id) and clear the discoverable session keys; schedule the cache/session purge; and either call the store plugin's delete for sent DocumentCopy rows or state on the deletion page that external copies stay.

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-021**. 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:** privacy / erasure completeness - **Location:** `src/postulo/accounts/deletion.py` (deletion.py:91-139) - **Found by:** completeness review: coverage - **Related:** PST-022 (#751) **Who can do it, and what they need.** No attacker is needed: the data subject who asked for erasure, a regulator, or anybody who later reads the database, an administrator's Invitations page, or a backup. The precondition is simply that the person was invited, or left a spreadsheet/vCard/candidate file waiting for review on a second device, before the account was deleted (by themselves, by an administrator via People > Delete, or by manage.py delete_account). **How it works.** 1. An administrator issues an invitation bound to maria@private.example with the note 'Maria from the bootcamp'; Maria accepts it (Invite.accept sets accepted_by). 2. On another browser/device Maria uploads a CSV or a vCard for review; the parsed content is held in her server-side session row (keys csv_import / vcard_file / kind_file_* / candidate). 3. The account is deleted. delete_account() removes pending invitations created by the user and calls user.delete(); Invite.accepted_by and created_by are SET_NULL, and django_session has no relation to the user. 4. Verified in a scratch test: the Invite row still reads 'maria@private.example | Maria from the bootcamp' with accepted_by=None, and the same holds for an expired invitation created by a deleted administrator ('joe@private.example | Joe, referred by Sam', created_by=None). The session row still holds the spreadsheet (company 'SecretCorp Intl') and the vCard (name, email and telephone of a third party) after delete_account(): 'sessions before/after delete: 1 1, held personal data present before: True after: True'. 5. Every administrator keeps seeing the erased person's address in Server settings > Invitations ('For' column, status Accepted). **Impact.** The 'Your account and everything in it have been deleted' promise is false for the erased person's email address and free-text note (kept in invitation rows visible to every administrator), for anything they had waiting in a session (their own CV/spreadsheet and third parties' contact details from a vCard), and for copies already pushed to external stores (Paperless, WebDAV) because Postulo never calls a store's delete. All of it also rides along in every backup. … **Evidence.** ```text deletion.py:108-112: `report.rows["pending invitations revoked"] = (Invite.objects.filter(created_by=user).pending().delete()[0])` ... `_count, per_model = user.delete()`. models.py:746-752: `accepted_by = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, ...)`; 725/730: `email = models.EmailField(...)`, `note = models.CharField(...)`. Scratch output: 'INV accepted-by-deleted: maria@private.example | Maria from the bootcamp | accepted_by: None | created_by: 2' and 'SDEL session keys after: [[_auth_user_id, _auth_user_backend, _auth_user_hash, csv_import, vcard_file]]'. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep it low. Cite deletion.py:108-112 and models.py:736/748. The user-visible overclaim is views.py:545 ('everything in it') and server_views.py:331-340, not the delete page's invitation list, which is accurate. The main leftovers are Invite.email and Invite.note for accepted invitations and for invitations created by a deleted administrator. The session leftover is secondary. Self-service deletion does flush the current browser's session, and the stale rows are inert as credentials because _auth_user_id points at a deleted user. They still hold the stashed spreadsheet or vCard, and nothing ever prunes expired session rows (no clearsessions). That is a related retention weakness even without account deletion. Drop the external-store point or mark it informational, since those copies are in services the person controls. The Invitations page is visible only to administrators who typed the address themselves. Remediation: null or delete Invite.email and Invite.note when accepted_by or created_by is erased, or delete accepted invitations bound to the deleted account's address. … **Suggested remediation.** In delete_account: delete (or blank email and note on) every Invite whose accepted_by is the user or whose email matches an address the user held, not only pending ones the user created; delete the person's django_session rows (record the user pk in a column or iterate sessions and match _auth_user_id) and clear the discoverable session keys; schedule the cache/session purge; and either call the store plugin's delete for sent DocumentCopy rows or state on the deletion page that external copies stay.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:30 +00:00
Author
Owner

Fixed in fb5072037 on main.

Fixed in fb5072037 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#750
No description provided.