PST-013: API tokens, and any passkey or authenticator an attacker added, survive a password change, reset or recovery #742
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#742
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 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.
src/postulo/api/auth.py(api/auth.py:45-60)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.
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.
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_resetand social-login signals), revoke the owner's active API tokens, or at least tokens created since the last credential change. Requirereauthentication_requiredonApiTokenCreateView. 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.
Fixed in
7fbed2e72on main.