PST-019: The OAuth consent state is a signed bearer value: not bound to the browser session and not single-use #748
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#748
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-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.
src/postulo/plugins/consent.py(124; 124,137-176; 124-134,137-161; 124,145-170)Who can do it, and what they need. Somebody who can obtain a victim's freshly issued
statevalue (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.
start()signs{connection, owner}with the default salt and sends them to the provider with that state.codefor their mailbox./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, andrequest.useris the victim. The tokens for the attacker's mailbox are stored on the victim's connection.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 withConnection.objects.filter(pk=...)rather thanfor_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.
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 infinish(which also makes the state single-use), or use a salt specific to the flow. The same applies tomail_auth.finish_consent, which has the same shape.Suggested remediation. At
start, generate a random nonce, store it inrequest.session(keyed by connection pk) and include it in the signed state; atfinish, require the nonce to match and pop it so a state is single-use. Narrow the lookup withConnection.objects.for_user(request.user)and collapse the two messages into one.Also covered by this issue.
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. …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 …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.Fixed in
69f1adcacon main.