Server settings → Data protection: staff see every account's contacts and every connection's configuration #369

Closed
opened 2026-10-01 10:46:12 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, checking which staff pages read user-owned rows without narrowing them.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/server_views.py:600-638 (DataProtectionView.post and RecordOfProcessingView); core/gdpr.py, the two templates and the two tests are where they were.

The threat model promises that "administrators see accounts, never anyone's applications or documents" (docs/THREAT-MODEL.md, the row for another account on the same instance). Two staff-only pages added by #297 break that. The retention dry run lists, by name and company, the contacts of every account on the instance, and the record of processing prints each connection's label and configuration, which for the mail and calendar plugins is the other person's server and login name. Who a person is talking to at which company is the job search itself.

  • Where:
    • core/gdpr.py:348, retention_dry_run: Contact.objects.filter(created_at__lt=older_than) with no owner narrowing, rendered name by name in templates/server/data_protection.html:55-56.
    • core/gdpr.py:395-404, record_of_processing: Connection.objects.all() with "label" and "config": connection.config for each, rendered as <code>{{ recipient.config }}</code> in templates/server/record_of_processing.html:56-57.
    • Served by DataProtectionView.post and RecordOfProcessingView in core/server_views.py:599-637.
  • What happens: with a retention limit set, a staff account presses the dry run on Server settings → Data protection and gets the names of the recruiters and hiring managers every other account has recorded, with their companies and the dates they were recorded. On Record of processing the same account sees, for every member's IMAP or CalDAV connection, the host and username (postulo-imap keeps both in config; only the password is in the encrypted secrets) and whatever label the member gave it.
  • Who can trigger it: any staff account. On a family or shared instance that is not only the operator with shell access: PersonAdminView promotes any member to staff from the People page.
  • Why it matters: it is a documented boundary, and the staff pages are otherwise built to respect it. The listing is not even actionable by staff: the docstring says "the operator reads the report and performs the erasures one by one", but erasure (jobs/views.py:800) runs on a contact's own page, which is scoped with for_user() and answers 404 to anyone but the owner. tests/test_gdpr.py:323 and :343 assert the current behaviour (an admin sees another user's contact name and connection label), so the tests enshrine the leak.
  • Fix:
    • Give staff aggregates only: how many contacts on the instance are past the line, and how many per account if that is wanted, with no names, companies or dates.
    • Give each person their own dry run, narrowed with for_user(), on a page where they can act on it (the contact list or Settings).
    • On the record of processing, list recipients by plugin and kind with a count of connections, and at most the host a connection talks to; never another account's label, username or other configuration.
    • Rewrite the two tests above to assert the opposite.

Done when

  • A test in tests/test_gdpr.py logs in as staff with another user's contact past the retention line and a connection whose config holds a username, and asserts that neither the contact's name, its company, the connection's label nor the username appears on the Data protection dry run or the record of processing; it fails today.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, checking which staff pages read user-owned rows without narrowing them. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/server_views.py:600-638` (`DataProtectionView.post` and `RecordOfProcessingView`); `core/gdpr.py`, the two templates and the two tests are where they were. The threat model promises that "administrators see accounts, never anyone's applications or documents" (`docs/THREAT-MODEL.md`, the row for another account on the same instance). Two staff-only pages added by #297 break that. The retention dry run lists, by name and company, the contacts of every account on the instance, and the record of processing prints each connection's label and configuration, which for the mail and calendar plugins is the other person's server and login name. Who a person is talking to at which company is the job search itself. - **Where:** - `core/gdpr.py:348`, `retention_dry_run`: `Contact.objects.filter(created_at__lt=older_than)` with no owner narrowing, rendered name by name in `templates/server/data_protection.html:55-56`. - `core/gdpr.py:395-404`, `record_of_processing`: `Connection.objects.all()` with `"label"` and `"config": connection.config` for each, rendered as `<code>{{ recipient.config }}</code>` in `templates/server/record_of_processing.html:56-57`. - Served by `DataProtectionView.post` and `RecordOfProcessingView` in `core/server_views.py:599-637`. - **What happens:** with a retention limit set, a staff account presses the dry run on Server settings → Data protection and gets the names of the recruiters and hiring managers every other account has recorded, with their companies and the dates they were recorded. On *Record of processing* the same account sees, for every member's IMAP or CalDAV connection, the host and username (`postulo-imap` keeps both in `config`; only the password is in the encrypted secrets) and whatever label the member gave it. - **Who can trigger it:** any staff account. On a family or shared instance that is not only the operator with shell access: `PersonAdminView` promotes any member to staff from the People page. - **Why it matters:** it is a documented boundary, and the staff pages are otherwise built to respect it. The listing is not even actionable by staff: the docstring says "the operator reads the report and performs the erasures one by one", but erasure (`jobs/views.py:800`) runs on a contact's own page, which is scoped with `for_user()` and answers 404 to anyone but the owner. `tests/test_gdpr.py:323` and `:343` assert the current behaviour (an admin sees another user's contact name and connection label), so the tests enshrine the leak. - **Fix:** - Give staff aggregates only: how many contacts on the instance are past the line, and how many per account if that is wanted, with no names, companies or dates. - Give each person their own dry run, narrowed with `for_user()`, on a page where they can act on it (the contact list or Settings). - On the record of processing, list recipients by plugin and kind with a count of connections, and at most the host a connection talks to; never another account's label, username or other configuration. - Rewrite the two tests above to assert the opposite. ## Done when - A test in `tests/test_gdpr.py` logs in as staff with another user's contact past the retention line and a connection whose config holds a username, and asserts that neither the contact's name, its company, the connection's label nor the username appears on the Data protection dry run or the record of processing; it fails today.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:12 +00:00
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#369
No description provided.