PST-009: On a fresh instance, sign-up is open and the first account becomes superuser, so whoever arrives first owns the instance #738

Closed
opened 2026-10-10 10:55:48 +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-009. 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: bootstrap
  • Location: src/postulo/accounts/signals.py (50-62); src/postulo/core/site.py (289-296)
  • Found by: administrator surface and plugins, authentication and sessions, secrets and cryptography

Who can do it, and what they need. A stranger who reaches a freshly deployed (or fully emptied) instance before the operator; for the race, two strangers at once.

How it works.

  1. signup_open_now() returns True while User.objects.exists() is False, whatever POSTULO_REGISTRATION_OPEN says.
  2. The first user_signed_up sets is_staff and is_superuser and marks the address verified without any email round-trip.
  3. The check User.objects.exclude(pk=user.pk).exists() runs inside each request's transaction; under PostgreSQL READ COMMITTED two overlapping sign-ups each see no other row and both become superuser (no lock/unique guard, unlike the invite path which takes select_for_update).
  4. Deleting every account (PersonDeleteView cannot delete the last administrator, but manage.py or a restore of an empty DB can) reopens the door.

Impact. Instance takeover by a race to the sign-up form on a new deployment exposed before the operator signs up; documented as intended for the installer, but the window is not bounded and the race is not closed.

Evidence.

site.py:296 `return registration_open() or is_empty()`; signals.py:58-62 `if User.objects.exclude(pk=user.pk).exists(): return; user.is_staff = True; user.is_superuser = True`.

Verification (verdict: confirmed; severity after verification: Low). Keep as a narrow hardening gap. (a) The first-account bootstrap itself is documented intended behaviour, not a vulnerability; its only exposure is the time between the instance being reachable and the operator signing up, and the README order plus the loopback port binding keep that closed for a documented install. (b) The unlocked check in accounts/signals.py:58 matters only on PostgreSQL (docker/compose.postgres.yml); SQLite serialises requests. (c) Step 4 is wrong as stated for the application: PersonDeleteView, DeleteAccountView and manage.py delete_account all refuse the last administrator; only raw database or restore access empties the instance, which is operator-level and out of scope. Remediation: take a lock for the check (for example select_for_update on the SiteSettings row, or a PostgreSQL advisory lock) or re-check after commit, and optionally let an operator disable the empty-instance bootstrap.

Suggested remediation. Have the first sign-up require a bootstrap secret printed by the entrypoint/createsuperuser (or POSTULO_BOOTSTRAP_TOKEN), or at least take a table-level lock / select_for_update on a sentinel row before deciding who is first; log the event at WARNING.

Also covered by this issue.

  • Sign-up is open and the first account becomes superuser while the instance is empty, so a stranger who reaches a freshly deployed public instance first owns it (src/postulo/core/site.py (289-296); Low). Complete takeover of the instance at bootstrap. The window is usually short, but on a public deployment it is a race the operator may not know they are in; the comment assumes 'the person who just installed Postulo is the only one who can reach it'.
  • Fresh-install bootstrap: sign-up is open and the first account, by password or by SSO, becomes superuser with a trusted address (src/postulo/core/site.py (289-296); Informational). Documented and deliberate ('somebody has to'), and bounded to the first minutes of an install; recorded so the synthesis can say it was considered. Two overlapping first sign-ups could both become administrators, since the check is not serialised.
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-009**. 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:** bootstrap - **Location:** `src/postulo/accounts/signals.py` (50-62); `src/postulo/core/site.py` (289-296) - **Found by:** administrator surface and plugins, authentication and sessions, secrets and cryptography **Who can do it, and what they need.** A stranger who reaches a freshly deployed (or fully emptied) instance before the operator; for the race, two strangers at once. **How it works.** 1. `signup_open_now()` returns True while `User.objects.exists()` is False, whatever POSTULO_REGISTRATION_OPEN says. 2. The first `user_signed_up` sets is_staff and is_superuser and marks the address verified without any email round-trip. 3. The check `User.objects.exclude(pk=user.pk).exists()` runs inside each request's transaction; under PostgreSQL READ COMMITTED two overlapping sign-ups each see no other row and both become superuser (no lock/unique guard, unlike the invite path which takes select_for_update). 4. Deleting every account (PersonDeleteView cannot delete the last administrator, but `manage.py` or a restore of an empty DB can) reopens the door. **Impact.** Instance takeover by a race to the sign-up form on a new deployment exposed before the operator signs up; documented as intended for the installer, but the window is not bounded and the race is not closed. **Evidence.** ```text site.py:296 `return registration_open() or is_empty()`; signals.py:58-62 `if User.objects.exclude(pk=user.pk).exists(): return; user.is_staff = True; user.is_superuser = True`. ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep as a narrow hardening gap. (a) The first-account bootstrap itself is documented intended behaviour, not a vulnerability; its only exposure is the time between the instance being reachable and the operator signing up, and the README order plus the loopback port binding keep that closed for a documented install. (b) The unlocked check in accounts/signals.py:58 matters only on PostgreSQL (docker/compose.postgres.yml); SQLite serialises requests. (c) Step 4 is wrong as stated for the application: PersonDeleteView, DeleteAccountView and `manage.py delete_account` all refuse the last administrator; only raw database or restore access empties the instance, which is operator-level and out of scope. Remediation: take a lock for the check (for example select_for_update on the SiteSettings row, or a PostgreSQL advisory lock) or re-check after commit, and optionally let an operator disable the empty-instance bootstrap. **Suggested remediation.** Have the first sign-up require a bootstrap secret printed by the entrypoint/`createsuperuser` (or POSTULO_BOOTSTRAP_TOKEN), or at least take a table-level lock / `select_for_update` on a sentinel row before deciding who is first; log the event at WARNING. **Also covered by this issue.** - **Sign-up is open and the first account becomes superuser while the instance is empty, so a stranger who reaches a freshly deployed public instance first owns it** (`src/postulo/core/site.py` (289-296); Low). Complete takeover of the instance at bootstrap. The window is usually short, but on a public deployment it is a race the operator may not know they are in; the comment assumes 'the person who just installed Postulo is the only one who can reach it'. - **Fresh-install bootstrap: sign-up is open and the first account, by password or by SSO, becomes superuser with a trusted address** (`src/postulo/core/site.py` (289-296); Informational). Documented and deliberate ('somebody has to'), and bounded to the first minutes of an install; recorded so the synthesis can say it was considered. Two overlapping first sign-ups could both become administrators, since the check is not serialised.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:22 +00:00
Author
Owner

Fixed in b4fa62b67 on main.

Fixed in b4fa62b67 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#738
No description provided.