PST-019: The OAuth consent state is a signed bearer value: not bound to the browser session and not single-use #748

Closed
opened 2026-10-10 10:55:57 +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-019. 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: oauth-state-binding / scoping-idiom
  • Location: src/postulo/plugins/consent.py (124; 124,137-176; 124-134,137-161; 124,145-170)
  • Found by: CSRF, methods and redirects, administrator surface and plugins, ownership review of plugins-views, secrets and cryptography

Who can do it, and what they need. Somebody who can obtain a victim's freshly issued state value (it travels only in the authorise redirect the victim's browser follows; a cross-origin Referer is stripped to origin by modern browsers, so the realistic sources are a provider's logs or a shared machine) and who can get the signed-in victim to open a callback URL within 15 minutes. That precondition is hard, which is why this is low.

How it works.

  1. Victim presses Connect on an own-mail (or any consent) connection; start() signs {connection, owner} with the default salt and sends them to the provider with that state.
  2. Attacker captures the state.
  3. Attacker completes their own authorisation at the same provider with the instance's client_id (shared on an instance where the operator hands one registration to everybody, as the own-mail help text suggests) and obtains a code for their mailbox.
  4. Attacker sends the victim a link to /settings/connections/consent/?code=<attacker code>&state=<victim state>. finish() accepts: the signature is valid, within 15 minutes, the connection's owner is the victim, and request.user is the victim. The tokens for the attacker's mailbox are stored on the victim's connection.
  5. The victim's correspondence (core.correspondence.send) now leaves through the attacker's mailbox, where the attacker reads it. The state is also reusable within its window, so one capture serves repeated attempts. Separately, finish() fetches with Connection.objects.filter(pk=...) rather than for_user(request.user) and distinguishes 'no longer exists' from 'belongs to somebody else'; only a validly signed state reaches that branch, so there is no enumeration path, but it is the project's rule 2 broken at one place.

Impact. If achieved: the victim's outgoing mail is relayed through, and readable in, an attacker-controlled mailbox until they notice the summary line on the connection. Realistic likelihood is low because the state is not exposed to third parties by normal browser behaviour; recorded so the verification stage can decide.

Evidence.

consent.py:124 `state = signing.dumps({"connection": connection.pk, "owner": connection.owner_id})` (no per-session nonce, default salt). consent.py:147-157 `payload = signing.loads(state, max_age=STATE_MAX_AGE) ... connection = Connection.objects.filter(pk=payload.get("connection")).first(); if connection is None or connection.owner_id != payload.get("owner"): raise ConsentFailed("That connection no longer exists."); if connection.owner_id != getattr(request.user, "pk", None): raise ConsentFailed("That connection belongs to somebody else.")`. No session write in `start`, no session read in `finish`.

Verification (verdict: confirmed; severity after verification: Low). Same location (consent.py:124, 147-158), with the attacker precondition narrowed: the attacker needs the victim's live state value, and none of the channels the auditor lists is open to an in-scope attacker (Postulo's Referer policy is same-origin, so the 'cross-origin Referer' route does not apply to Postulo pages). The 'unscoped lookup' sub-point should be dropped. Remediation: put a random nonce in the signed state and store it in the session at start, compare and pop it in finish (which also makes the state single-use), or use a salt specific to the flow. The same applies to mail_auth.finish_consent, which has the same shape.

Suggested remediation. At start, generate a random nonce, store it in request.session (keyed by connection pk) and include it in the signed state; at finish, require the nonce to match and pop it so a state is single-use. Narrow the lookup with Connection.objects.for_user(request.user) and collapse the two messages into one.

Also covered by this issue.

  • OAuth consent 'state' is a reusable, session-unbound signed value: a leaked state plus an attacker's authorization code binds the attacker's provider account to the victim's connection (src/postulo/plugins/consent.py (124,137-176); Low). Covert interception of the victim's outgoing correspondence (or, for the instance mailbox, of password resets for all users) until somebody notices the Test result. …
  • OAuth consent callback accepts a state that is a signed bearer value: not bound to the browser session, not single-use, no PKCE (src/postulo/plugins/consent.py (124-134,137-161); Low). The victim's connection now acts against the attacker's provider account: documents the victim 'sends copies of', calendars or webhooks go to the attacker's storage; with the mail variant, every password-reset or sign-in-code message the instance sends is sent through (and visible in the Sent …
  • OAuth consent state is not bound to the browser session: a captured state value can be replayed with an attacker-supplied code to bind the attacker's provider account to the victim's connection — or to the instance's own mailbox that sends password resets (src/postulo/plugins/consent.py (124,145-170); Low). Login-CSRF style binding of an attacker's grant to a victim's connection; in the instance-mailbox variant, interception of password-reset mail and therefore account takeover of any member. Narrow precondition (a leaked state within 15 min), which is why this is rated low despite the impact.
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-019**. 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:** oauth-state-binding / scoping-idiom - **Location:** `src/postulo/plugins/consent.py` (124; 124,137-176; 124-134,137-161; 124,145-170) - **Found by:** CSRF, methods and redirects, administrator surface and plugins, ownership review of plugins-views, secrets and cryptography **Who can do it, and what they need.** Somebody who can obtain a victim's freshly issued `state` value (it travels only in the authorise redirect the victim's browser follows; a cross-origin Referer is stripped to origin by modern browsers, so the realistic sources are a provider's logs or a shared machine) and who can get the signed-in victim to open a callback URL within 15 minutes. That precondition is hard, which is why this is low. **How it works.** 1. Victim presses Connect on an own-mail (or any consent) connection; `start()` signs `{connection, owner}` with the default salt and sends them to the provider with that state. 2. Attacker captures the state. 3. Attacker completes their own authorisation at the same provider with the instance's client_id (shared on an instance where the operator hands one registration to everybody, as the own-mail help text suggests) and obtains a `code` for *their* mailbox. 4. Attacker sends the victim a link to `/settings/connections/consent/?code=<attacker code>&state=<victim state>`. `finish()` accepts: the signature is valid, within 15 minutes, the connection's owner is the victim, and `request.user` is the victim. The tokens for the attacker's mailbox are stored on the victim's connection. 5. The victim's correspondence (`core.correspondence.send`) now leaves through the attacker's mailbox, where the attacker reads it. The state is also reusable within its window, so one capture serves repeated attempts. Separately, `finish()` fetches with `Connection.objects.filter(pk=...)` rather than `for_user(request.user)` and distinguishes 'no longer exists' from 'belongs to somebody else'; only a validly signed state reaches that branch, so there is no enumeration path, but it is the project's rule 2 broken at one place. **Impact.** If achieved: the victim's outgoing mail is relayed through, and readable in, an attacker-controlled mailbox until they notice the summary line on the connection. Realistic likelihood is low because the state is not exposed to third parties by normal browser behaviour; recorded so the verification stage can decide. **Evidence.** ```text consent.py:124 `state = signing.dumps({"connection": connection.pk, "owner": connection.owner_id})` (no per-session nonce, default salt). consent.py:147-157 `payload = signing.loads(state, max_age=STATE_MAX_AGE) ... connection = Connection.objects.filter(pk=payload.get("connection")).first(); if connection is None or connection.owner_id != payload.get("owner"): raise ConsentFailed("That connection no longer exists."); if connection.owner_id != getattr(request.user, "pk", None): raise ConsentFailed("That connection belongs to somebody else.")`. No session write in `start`, no session read in `finish`. ``` **Verification** (verdict: confirmed; severity after verification: Low). Same location (consent.py:124, 147-158), with the attacker precondition narrowed: the attacker needs the victim's live state value, and none of the channels the auditor lists is open to an in-scope attacker (Postulo's Referer policy is same-origin, so the 'cross-origin Referer' route does not apply to Postulo pages). The 'unscoped lookup' sub-point should be dropped. Remediation: put a random nonce in the signed state and store it in the session at `start`, compare and pop it in `finish` (which also makes the state single-use), or use a salt specific to the flow. The same applies to `mail_auth.finish_consent`, which has the same shape. **Suggested remediation.** At `start`, generate a random nonce, store it in `request.session` (keyed by connection pk) and include it in the signed state; at `finish`, require the nonce to match and pop it so a state is single-use. Narrow the lookup with `Connection.objects.for_user(request.user)` and collapse the two messages into one. **Also covered by this issue.** - **OAuth consent 'state' is a reusable, session-unbound signed value: a leaked state plus an attacker's authorization code binds the attacker's provider account to the victim's connection** (`src/postulo/plugins/consent.py` (124,137-176); Low). Covert interception of the victim's outgoing correspondence (or, for the instance mailbox, of password resets for all users) until somebody notices the Test result. … - **OAuth consent callback accepts a state that is a signed bearer value: not bound to the browser session, not single-use, no PKCE** (`src/postulo/plugins/consent.py` (124-134,137-161); Low). The victim's connection now acts against the attacker's provider account: documents the victim 'sends copies of', calendars or webhooks go to the attacker's storage; with the mail variant, every password-reset or sign-in-code message the instance sends is sent through (and visible in the Sent … - **OAuth consent state is not bound to the browser session: a captured state value can be replayed with an attacker-supplied code to bind the attacker's provider account to the victim's connection — or to the instance's own mailbox that sends password resets** (`src/postulo/plugins/consent.py` (124,145-170); Low). Login-CSRF style binding of an attacker's grant to a victim's connection; in the instance-mailbox variant, interception of password-reset mail and therefore account takeover of any member. Narrow precondition (a leaked state within 15 min), which is why this is rated low despite the impact.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:29 +00:00
Author
Owner

Fixed in 69f1adcac on main.

Fixed in 69f1adcac 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#748
No description provided.