PST-035: API token creation needs no re-authentication, defaults to never expiring and is not rate-limited #764

Closed
opened 2026-10-10 10:56:09 +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-035. 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: token-lifecycle / hardening
  • Location: src/postulo/api/views.py (437-466)
  • Found by: REST API
  • Related: PST-013 (#742)

Who can do it, and what they need. Someone with momentary control of a signed-in session: an unattended unlocked browser, or a stolen session cookie. Precondition: a live session; CSRF protection and the CSP already stop a cross-site page from doing this.

How it works.

  1. One POST to /capture-tokens/new/ with name, scopes read,write,documents:read,captures,listings:bind and expiry left at its default 'Never'.
  2. The secret is shown once; the attacker copies it.
  3. The token keeps working after the victim signs out and after a password change, since tokens are independent of sessions and nothing in the account's security flows revokes them; the owner sees it only if they visit the token list.

Impact. Converts a transient session compromise into a durable, full-scope API credential (every document downloadable under documents:read). Standard for many APIs, but the application otherwise treats tokens as the most sensitive thing it mints.

Evidence.

views.py 450-460: `form = ApiTokenForm(request.POST, user=request.user) ... _token, raw = ApiToken.issue(request.user, form.cleaned_data["name"], scopes=form.cleaned_data["scopes"], expires_at=form.expires_at())` with no password re-entry or recent-login check; forms.py: `EXPIRY_CHOICES = [("", _("Never")), ("30", ...), ...]` and `expires = forms.ChoiceField(... required=False)`.

Verification (verdict: confirmed; severity after verification: Low). Line numbers were wrong: ApiTokenCreateView.post is src/postulo/api/views.py:45-61 (issue call at :50-55), and EXPIRY_CHOICES / the expires field are src/postulo/api/forms.py:9-14 and :32. Precondition: a live session, i.e. an unattended unlocked browser or a stolen session cookie, which the threat model largely treats as out of scope. Impact: durable, full-scope credential that survives sign-out and password change. Remediation: wrap ApiTokenCreateView.post in reauthentication_required as account deletion and backups are, consider a default or maximum expiry, and revoke or flag tokens on password change and from an account-security notice.

Suggested remediation. Require re-entering the password (or a recent-authentication window, as allauth's reauthentication flow provides) before issuing a token; default the expiry to a finite period; revoke or at least flag tokens on password change and show the number of live tokens on the security settings page.

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-035**. 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:** token-lifecycle / hardening - **Location:** `src/postulo/api/views.py` (437-466) - **Found by:** REST API - **Related:** PST-013 (#742) **Who can do it, and what they need.** Someone with momentary control of a signed-in session: an unattended unlocked browser, or a stolen session cookie. Precondition: a live session; CSRF protection and the CSP already stop a cross-site page from doing this. **How it works.** 1. One POST to `/capture-tokens/new/` with name, scopes `read,write,documents:read,captures,listings:bind` and expiry left at its default 'Never'. 2. The secret is shown once; the attacker copies it. 3. The token keeps working after the victim signs out and after a password change, since tokens are independent of sessions and nothing in the account's security flows revokes them; the owner sees it only if they visit the token list. **Impact.** Converts a transient session compromise into a durable, full-scope API credential (every document downloadable under `documents:read`). Standard for many APIs, but the application otherwise treats tokens as the most sensitive thing it mints. **Evidence.** ```text views.py 450-460: `form = ApiTokenForm(request.POST, user=request.user) ... _token, raw = ApiToken.issue(request.user, form.cleaned_data["name"], scopes=form.cleaned_data["scopes"], expires_at=form.expires_at())` with no password re-entry or recent-login check; forms.py: `EXPIRY_CHOICES = [("", _("Never")), ("30", ...), ...]` and `expires = forms.ChoiceField(... required=False)`. ``` **Verification** (verdict: confirmed; severity after verification: Low). Line numbers were wrong: ApiTokenCreateView.post is src/postulo/api/views.py:45-61 (issue call at :50-55), and EXPIRY_CHOICES / the expires field are src/postulo/api/forms.py:9-14 and :32. Precondition: a live session, i.e. an unattended unlocked browser or a stolen session cookie, which the threat model largely treats as out of scope. Impact: durable, full-scope credential that survives sign-out and password change. Remediation: wrap ApiTokenCreateView.post in reauthentication_required as account deletion and backups are, consider a default or maximum expiry, and revoke or flag tokens on password change and from an account-security notice. **Suggested remediation.** Require re-entering the password (or a recent-authentication window, as allauth's reauthentication flow provides) before issuing a token; default the expiry to a finite period; revoke or at least flag tokens on password change and show the number of live tokens on the security settings page.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:41 +00:00
Author
Owner

Fixed in c34e21966 on main.

Fixed in c34e21966 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#764
No description provided.