PST-097: The first-account-becomes-administrator check may be racy on PostgreSQL #826

Closed
opened 2026-10-10 10:57:03 +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-097. Severity: Informational (not settled: see below).

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: Informational (not settled: see below)
  • Category: privilege assignment race
  • Location: src/postulo/accounts/signals.py (50-64)
  • Found by: ownership review of accounts
  • Related: PST-009 (#738)

Who can do it, and what they need. Two people (or one person with two browsers) who both reach the sign-up page of a freshly installed, still-empty instance on PostgreSQL and submit within the same few milliseconds. Precondition: the instance has zero accounts (signup_open_now() is True via site.is_empty()), and PostgreSQL's default READ COMMITTED isolation.

How it works.

  1. Request A inserts user A inside its request transaction (ATOMIC_REQUESTS).
  2. Request B inserts user B inside its own transaction.
  3. A's user_signed_up receiver runs User.objects.exclude(pk=user.pk).exists(); B's row is uncommitted and invisible, so A is made is_staff and is_superuser.
  4. B's receiver runs the same query; A's row is still uncommitted (or A's commit has not landed), so B is also made superuser. Both commit; the instance has two superusers, one of them unintended. SQLite serialises writers so it cannot be reproduced there; not verified by test.

Impact. An uninvited person who happens to race the installer on first start becomes a superuser. The window is tiny and only exists before the first account commits, so the path is speculative; recorded because a superuser is the highest privilege in the system.

Evidence.

signals.py:57-61:
    User = type(user)
    if User.objects.exclude(pk=user.pk).exists():
        return
    user.is_staff = True
    user.is_superuser = True

site.py:289-296: signup_open_now() returns registration_open() or is_empty(); is_empty() is `not get_user_model().objects.exists()` with no lock.

Verification (verdict: uncertain; severity after verification: Informational; first-pass claim: Low). Missing to settle it: two real PostgreSQL connections, each running a sign-up transaction on an empty database from different client IPs, checking whether both end up is_superuser. If it is real, the fix is to take a lock around the check (select_for_update on the SiteSettings row, or a PostgreSQL advisory lock) or to make the first-administrator decision from a unique constraint. The scenario should be restated as 'two overlapping sign-ups from different addresses', since the same-IP case is probably serialised by the database cache.

Suggested remediation. Take a lock before deciding (e.g. select_for_update on the SiteSettings row, which is the natural instance-wide mutex and is already read on signup), or use a database-level guard such as a partial unique index on is_superuser for the bootstrap case, so that only one transaction can win the 'first account' decision.

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-097**. Severity: **Informational (not settled: see below)**. 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:** Informational (not settled: see below) - **Category:** privilege assignment race - **Location:** `src/postulo/accounts/signals.py` (50-64) - **Found by:** ownership review of accounts - **Related:** PST-009 (#738) **Who can do it, and what they need.** Two people (or one person with two browsers) who both reach the sign-up page of a freshly installed, still-empty instance on PostgreSQL and submit within the same few milliseconds. Precondition: the instance has zero accounts (signup_open_now() is True via site.is_empty()), and PostgreSQL's default READ COMMITTED isolation. **How it works.** 1. Request A inserts user A inside its request transaction (ATOMIC_REQUESTS). 2. Request B inserts user B inside its own transaction. 3. A's user_signed_up receiver runs `User.objects.exclude(pk=user.pk).exists()`; B's row is uncommitted and invisible, so A is made is_staff and is_superuser. 4. B's receiver runs the same query; A's row is still uncommitted (or A's commit has not landed), so B is also made superuser. Both commit; the instance has two superusers, one of them unintended. SQLite serialises writers so it cannot be reproduced there; not verified by test. **Impact.** An uninvited person who happens to race the installer on first start becomes a superuser. The window is tiny and only exists before the first account commits, so the path is speculative; recorded because a superuser is the highest privilege in the system. **Evidence.** ```text signals.py:57-61: User = type(user) if User.objects.exclude(pk=user.pk).exists(): return user.is_staff = True user.is_superuser = True site.py:289-296: signup_open_now() returns registration_open() or is_empty(); is_empty() is `not get_user_model().objects.exists()` with no lock. ``` **Verification** (verdict: uncertain; severity after verification: Informational; first-pass claim: Low). Missing to settle it: two real PostgreSQL connections, each running a sign-up transaction on an empty database from different client IPs, checking whether both end up is_superuser. If it is real, the fix is to take a lock around the check (select_for_update on the SiteSettings row, or a PostgreSQL advisory lock) or to make the first-administrator decision from a unique constraint. The scenario should be restated as 'two overlapping sign-ups from different addresses', since the same-IP case is probably serialised by the database cache. **Suggested remediation.** Take a lock before deciding (e.g. select_for_update on the SiteSettings row, which is the natural instance-wide mutex and is already read on signup), or use a database-level guard such as a partial unique index on is_superuser for the bootstrap case, so that only one transaction can win the 'first account' decision.
tiagoagueda added this to the 1.0.0 milestone 2026-10-10 10:58:23 +00:00
Author
Owner

Already fixed by the lock added for #738 (9f6c2e0 and its tests, in the 0.6.0 round): the first-account check takes select_for_update on the instance's one settings row inside a transaction, so a second sign-up waits for the first to commit and then finds an account. SQLite serialises writers anyway. Nothing further to change.

Already fixed by the lock added for #738 (9f6c2e0 and its tests, in the 0.6.0 round): the first-account check takes select_for_update on the instance's one settings row inside a transaction, so a second sign-up waits for the first to commit and then finds an account. SQLite serialises writers anyway. Nothing further to change.
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#826
No description provided.