PST-013: API tokens, and any passkey or authenticator an attacker added, survive a password change, reset or recovery #742

Closed
opened 2026-10-10 10:55:52 +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-013. 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: session management / credential revocation (chain)
  • Location: src/postulo/api/auth.py (api/auth.py:45-60)
  • Found by: completeness review: chains
  • Related: PST-010 (#739), PST-011 (#740)

Who can do it, and what they need. A member who has pre-claimed a victim's email address unverified (the confirmed high finding) or who holds a stolen session. The attacker creates credentials that are not tied to the password: an API token with read/write/documents:read scopes (no re-authentication needed, never expires by default) and optionally a passkey or TOTP.

How it works.

  1. Attacker's account A holds victim@x unverified. A also holds an API token (read, write, documents:read) and optionally a passkey.
  2. The victim signs in through SSO. Alternatively the victim completes a password reset or a sign-in code for the pre-claimed address. The victim now works inside account A.
  3. allauth's email-authentication flow calls wipe_password() to lock the attacker out. It only runs user.set_unusable_password(). It does not touch API tokens, authenticators or other footholds.
  4. The attacker keeps calling GET /api/v1/profile, /applications, /documents/... with the token, or signs in with the passkey, and reads and writes everything the victim adds from then on.
    Separately, the same persistence defeats ordinary remediation: after an administrator's recovery link or the owner's own password change, a token minted during a compromise keeps working.
    Verified: after recovery.spend() and after set_unusable_password(), api_auth.lookup(raw) still returned the token and GET /api/v1/me and /api/v1/profile answered 200.

Impact. The standard remediation for a hijacked or pre-hijacked account (change the password, or have an administrator issue a recovery link) leaves a silent, non-expiring read/write channel to the victim's CV, address, birth date, nationalities, employment history and documents. This turns the pre-claim/SSO capture from a one-time mix-up into persistent covert access.

Evidence.

auth.py lookup(): `ApiToken.objects.active()...filter(token_hash=...)`, `if record is None or not record.owner.is_active: return None`. There is no comparison with the password hash or a credential-change timestamp. recovery.py spend(): `person.set_password(new_password); person.save(update_fields=["password"])`. Scratch test: 'after recovery link spent -> token still valid: True', 'after password wipe -> token still valid: True', 'GET /api/v1/profile -> 200'.

Verification (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). Downgrade from medium to low. It rises toward medium only if the pre-claim finding is independently confirmed as stated, because the persistence of API tokens, passkeys and TOTP is what defeats allauth's wipe_password lock-out in that chain. Corrections: the cited line range for the SSO settings is 351-352, not 336-352. The attacker's web sessions do die on password change through Django's session auth hash, so the surviving footholds are API tokens, passkeys and TOTP devices. Passkey and TOTP persistence across recovery is documented behaviour in recovery.py, and persistence across wipe_password is upstream allauth behaviour. The Postulo-specific part is API tokens. Suggested remediation: on password change, reset or recovery spend, and on allauth's wipe_password path (hook the password_changed, password_reset and social-login signals), revoke the owner's active API tokens, or at least tokens created since the last credential change. Require reauthentication_required on ApiTokenCreateView. Consider a default expiry for tokens.

Suggested remediation. On password change, password reset, recovery-link spend and the SSO email-authentication wipe, revoke the account's API tokens (or at least those created before the change) and offer to remove authenticators added since. Better, record a credential-epoch on the user and have ApiToken.lookup refuse tokens issued before it. Require reauthentication to mint a token and set a default expiry.

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-013**. 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:** session management / credential revocation (chain) - **Location:** `src/postulo/api/auth.py` (api/auth.py:45-60) - **Found by:** completeness review: chains - **Related:** PST-010 (#739), PST-011 (#740) **Who can do it, and what they need.** A member who has pre-claimed a victim's email address unverified (the confirmed high finding) or who holds a stolen session. The attacker creates credentials that are not tied to the password: an API token with read/write/documents:read scopes (no re-authentication needed, never expires by default) and optionally a passkey or TOTP. **How it works.** 1. Attacker's account A holds victim@x unverified. A also holds an API token (read, write, documents:read) and optionally a passkey. 2. The victim signs in through SSO. Alternatively the victim completes a password reset or a sign-in code for the pre-claimed address. The victim now works inside account A. 3. allauth's email-authentication flow calls wipe_password() to lock the attacker out. It only runs user.set_unusable_password(). It does not touch API tokens, authenticators or other footholds. 4. The attacker keeps calling GET /api/v1/profile, /applications, /documents/... with the token, or signs in with the passkey, and reads and writes everything the victim adds from then on. Separately, the same persistence defeats ordinary remediation: after an administrator's recovery link or the owner's own password change, a token minted during a compromise keeps working. Verified: after recovery.spend() and after set_unusable_password(), api_auth.lookup(raw) still returned the token and GET /api/v1/me and /api/v1/profile answered 200. **Impact.** The standard remediation for a hijacked or pre-hijacked account (change the password, or have an administrator issue a recovery link) leaves a silent, non-expiring read/write channel to the victim's CV, address, birth date, nationalities, employment history and documents. This turns the pre-claim/SSO capture from a one-time mix-up into persistent covert access. **Evidence.** ```text auth.py lookup(): `ApiToken.objects.active()...filter(token_hash=...)`, `if record is None or not record.owner.is_active: return None`. There is no comparison with the password hash or a credential-change timestamp. recovery.py spend(): `person.set_password(new_password); person.save(update_fields=["password"])`. Scratch test: 'after recovery link spent -> token still valid: True', 'after password wipe -> token still valid: True', 'GET /api/v1/profile -> 200'. ``` **Verification** (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). Downgrade from medium to low. It rises toward medium only if the pre-claim finding is independently confirmed as stated, because the persistence of API tokens, passkeys and TOTP is what defeats allauth's wipe_password lock-out in that chain. Corrections: the cited line range for the SSO settings is 351-352, not 336-352. The attacker's web sessions do die on password change through Django's session auth hash, so the surviving footholds are API tokens, passkeys and TOTP devices. Passkey and TOTP persistence across recovery is documented behaviour in recovery.py, and persistence across wipe_password is upstream allauth behaviour. The Postulo-specific part is API tokens. Suggested remediation: on password change, reset or recovery spend, and on allauth's wipe_password path (hook the `password_changed`, `password_reset` and social-login signals), revoke the owner's active API tokens, or at least tokens created since the last credential change. Require `reauthentication_required` on `ApiTokenCreateView`. Consider a default expiry for tokens. **Suggested remediation.** On password change, password reset, recovery-link spend and the SSO email-authentication wipe, revoke the account's API tokens (or at least those created before the change) and offer to remove authenticators added since. Better, record a credential-epoch on the user and have ApiToken.lookup refuse tokens issued before it. Require reauthentication to mint a token and set a default expiry.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:25 +00:00
Author
Owner

Fixed in 7fbed2e72 on main.

Fixed in 7fbed2e72 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#742
No description provided.