PST-020: The Django admin's profile inline exposes and edits every account's birth data, nationalities and gender #749

Closed
opened 2026-10-10 10:55:58 +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-020. 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: information disclosure / administrator over-reach
  • Location: src/postulo/accounts/admin.py (10-16; 10-16,41-48)
  • Found by: administrator surface and plugins, information disclosure, ownership review of accounts
  • Related: PST-011 (#740)

Who can do it, and what they need. A superuser (the instance's first account, or one made with createsuperuser) on an instance where the operator has mounted Django's admin (POSTULO_ADMIN_URL set; off by default). An administrator appointed through People is only is_staff and sees an empty admin index, so this does not reach them.

How it works.

  1. Operator mounts the admin at POSTULO_ADMIN_URL.
  2. Superuser opens <admin>/accounts/user/<pk>/change/ for any other account.
  3. The stacked ProfileInline renders the full Profile with only the two legacy file columns excluded: form_of_address, pronouns, gender, birth_date, birth_place, birth_country, nationalities, nationality_scope, headline, location, table_settings and every preference column, all editable. Confirmed by a scratch test (/tmp/.../verify/own-accounts/test_admin_profile_inline.py), which printed the inline's field list including birth_date, birth_place, birth_country, nationalities, gender, form_of_address, pronouns, headline, location, table_settings.

Impact. docs/THREAT-MODEL.md says the mounted admin 'holds accounts, invitations and token metadata and no person's records (#368)' and that date/place of birth, nationalities and gender are 'never in a list' and the most identifying things a profile holds. tests/security/test_admin_exposure.py walks only OwnedModel subclasses, so Profile (a OneToOne on User, not an OwnedModel) escapes the check. A superuser can also silently rewrite these columns (and a person's table/dashboard preferences) with no event or notice to the person. …

Evidence.

admin.py:10-16:
    class ProfileInline(admin.StackedInline):
        model = Profile
        can_delete = False
        extra = 0
        exclude = ("avatar", "gravatar_image")

admin.py:48:
    inlines = (ProfileInline,)

Scratch output: SENSITIVE SHOWN ['birth_country', 'birth_date', 'birth_place', 'form_of_address', 'gender', 'headline', 'location', 'nationalities', 'pronouns', 'table_settings']

Verification (verdict: confirmed; severity after verification: Low). Attacker: only a superuser on an instance whose operator mounted the admin. Superuser status is not handed out by People, so this is the operator or someone the operator promoted in the admin itself. It is a gap against the documented boundary (#368) rather than an escalation, because that party can already read the database. Fix: drop the inline or restrict it to non-sensitive preference fields, and extend the exposure test to walk every model with a relation to the user (Profile) or assert the inline's field list.

Suggested remediation. Either drop ProfileInline from UserAdmin (the admin's stated job is account metadata), or restrict it with an explicit fields tuple limited to non-personal preference columns (language, time_zone, theme) and mark the rest readonly or excluded. Extend test_admin_exposure.py's walk to assert that no admin form, inline included, offers the columns core/personal.py governs.

Also covered by this issue.

  • Django admin's User change page exposes every member's Profile (date and place of birth, nationalities, gender, pronouns, location) through ProfileInline, contradicting #368 and escaping test_admin_exposure.py (src/postulo/accounts/admin.py (10-16,41-48); Informational). The threat model names date/place of birth, nationality and gender as 'the most identifying things a profile can hold' and promises they are 'never in a list ... an administrator's'. The admin shows and lets a superuser edit them for everyone, and edits bypass core/personal.py's rules. …
  • Django admin's ProfileInline shows every member's birth date, place and country of birth, nationalities, gender and pronouns to a superuser when the admin is mounted (src/postulo/accounts/admin.py (10-16,41-48); Informational). The most identifying columns the application holds (identity-check data, and nationality/gender which the threat model treats as sensitive) are listed for every member on one page of the mounted admin, contrary to the documented boundary for administrators. …
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-020**. 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:** information disclosure / administrator over-reach - **Location:** `src/postulo/accounts/admin.py` (10-16; 10-16,41-48) - **Found by:** administrator surface and plugins, information disclosure, ownership review of accounts - **Related:** PST-011 (#740) **Who can do it, and what they need.** A superuser (the instance's first account, or one made with createsuperuser) on an instance where the operator has mounted Django's admin (POSTULO_ADMIN_URL set; off by default). An administrator appointed through People is only is_staff and sees an empty admin index, so this does not reach them. **How it works.** 1. Operator mounts the admin at POSTULO_ADMIN_URL. 2. Superuser opens &lt;admin>/accounts/user/&lt;pk>/change/ for any other account. 3. The stacked ProfileInline renders the full Profile with only the two legacy file columns excluded: form_of_address, pronouns, gender, birth_date, birth_place, birth_country, nationalities, nationality_scope, headline, location, table_settings and every preference column, all editable. Confirmed by a scratch test (/tmp/.../verify/own-accounts/test_admin_profile_inline.py), which printed the inline's field list including birth_date, birth_place, birth_country, nationalities, gender, form_of_address, pronouns, headline, location, table_settings. **Impact.** docs/THREAT-MODEL.md says the mounted admin 'holds accounts, invitations and token metadata and no person's records (#368)' and that date/place of birth, nationalities and gender are 'never in a list' and the most identifying things a profile holds. tests/security/test_admin_exposure.py walks only OwnedModel subclasses, so Profile (a OneToOne on User, not an OwnedModel) escapes the check. A superuser can also silently rewrite these columns (and a person's table/dashboard preferences) with no event or notice to the person. … **Evidence.** ```text admin.py:10-16: class ProfileInline(admin.StackedInline): model = Profile can_delete = False extra = 0 exclude = ("avatar", "gravatar_image") admin.py:48: inlines = (ProfileInline,) Scratch output: SENSITIVE SHOWN ['birth_country', 'birth_date', 'birth_place', 'form_of_address', 'gender', 'headline', 'location', 'nationalities', 'pronouns', 'table_settings'] ``` **Verification** (verdict: confirmed; severity after verification: Low). Attacker: only a superuser on an instance whose operator mounted the admin. Superuser status is not handed out by People, so this is the operator or someone the operator promoted in the admin itself. It is a gap against the documented boundary (#368) rather than an escalation, because that party can already read the database. Fix: drop the inline or restrict it to non-sensitive preference fields, and extend the exposure test to walk every model with a relation to the user (Profile) or assert the inline's field list. **Suggested remediation.** Either drop ProfileInline from UserAdmin (the admin's stated job is account metadata), or restrict it with an explicit `fields` tuple limited to non-personal preference columns (language, time_zone, theme) and mark the rest readonly or excluded. Extend test_admin_exposure.py's walk to assert that no admin form, inline included, offers the columns core/personal.py governs. **Also covered by this issue.** - **Django admin's User change page exposes every member's Profile (date and place of birth, nationalities, gender, pronouns, location) through ProfileInline, contradicting #368 and escaping test_admin_exposure.py** (`src/postulo/accounts/admin.py` (10-16,41-48); Informational). The threat model names date/place of birth, nationality and gender as 'the most identifying things a profile can hold' and promises they are 'never in a list ... an administrator's'. The admin shows and lets a superuser edit them for everyone, and edits bypass core/personal.py's rules. … - **Django admin's ProfileInline shows every member's birth date, place and country of birth, nationalities, gender and pronouns to a superuser when the admin is mounted** (`src/postulo/accounts/admin.py` (10-16,41-48); Informational). The most identifying columns the application holds (identity-check data, and nationality/gender which the threat model treats as sensitive) are listed for every member on one page of the mounted admin, contrary to the documented boundary for administrators. …
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:29 +00:00
Author
Owner

Fixed in 2dcd203aa on main.

Fixed in 2dcd203aa 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#749
No description provided.