fix(oidc): server-side intersect consent scopes; refuse to revive revoked entitlement rows #292

Merged
YousifShkara merged 1 commit from fix/BUNYIP-261-consent-scope-smuggling into main 2026-06-30 06:42:45 +02:00
Owner

BUNYIP-261: two of the consent-flow holes the 2026-06-30 audit named.

Scope smuggling. POST /v1/users/me/consents previously wrote body.scopes directly into user_application_access.granted_scopes without checking the request against the client's registered allowed_scopes. The consent screen took the missing set from the URL query, the user clicked Allow, and any scope name in that body landed forever. Combined with bunyip-web's consent.rs echoing the URL missing param verbatim into the hidden form field, a phishing link with ?missing=admin.god could permanently grant a fake scope for any client. The audit called this high-severity.

Now: the handler loads the client and intersects body.scopes with client.allowed_scopes before calling add_scopes_to_grant. A request whose scopes ALL fall outside the allowlist returns 400 ("no requested scope is in this client's allowed_scopes") so the caller's intent isn't silently downgraded to a no-op. Unknown or disabled client_id falls through to 400. The structured log line on the audit / debug path now records both requested and granted so the BUNYIP-234 consent-loop investigation can tell when an upstream caller asked for more than they got.

The audit's larger fix (a signed consent_ticket that closes the URL-param trust chain entirely) is deferred to a follow-up; rewriting bunyip-web's consent UI + adding the ticket table is heavier than this PR's scope. The intersection check closes the immediate exploit even without the ticket rework.

Refuse to revive revoked rows. grant_entitlement and add_scopes_to_grant both cleared revoked_at via ON CONFLICT DO UPDATE. That made the JIT baseline path at the next /authorize silently re-grant any entitlement an admin had explicitly revoked, and any subsequent user-driven "Allow" click would undo an admin's revocation. Add WHERE user_application_access.revoked_at IS NULL to both UPDATE branches so a revoked row stays revoked until an explicit admin re-grant. New INSERT rows are unaffected because the WHERE clause only gates the ON CONFLICT branch.

#BUNYIP-261

BUNYIP-261: two of the consent-flow holes the 2026-06-30 audit named. Scope smuggling. `POST /v1/users/me/consents` previously wrote `body.scopes` directly into `user_application_access.granted_scopes` without checking the request against the client's registered `allowed_scopes`. The consent screen took the `missing` set from the URL query, the user clicked Allow, and any scope name in that body landed forever. Combined with bunyip-web's consent.rs echoing the URL `missing` param verbatim into the hidden form field, a phishing link with `?missing=admin.god` could permanently grant a fake scope for any client. The audit called this high-severity. Now: the handler loads the client and intersects body.scopes with `client.allowed_scopes` before calling `add_scopes_to_grant`. A request whose scopes ALL fall outside the allowlist returns 400 ("no requested scope is in this client's allowed_scopes") so the caller's intent isn't silently downgraded to a no-op. Unknown or disabled client_id falls through to 400. The structured log line on the audit / debug path now records both `requested` and `granted` so the BUNYIP-234 consent-loop investigation can tell when an upstream caller asked for more than they got. The audit's larger fix (a signed consent_ticket that closes the URL-param trust chain entirely) is deferred to a follow-up; rewriting bunyip-web's consent UI + adding the ticket table is heavier than this PR's scope. The intersection check closes the immediate exploit even without the ticket rework. Refuse to revive revoked rows. `grant_entitlement` and `add_scopes_to_grant` both cleared `revoked_at` via `ON CONFLICT DO UPDATE`. That made the JIT baseline path at the next /authorize silently re-grant any entitlement an admin had explicitly revoked, and any subsequent user-driven "Allow" click would undo an admin's revocation. Add `WHERE user_application_access.revoked_at IS NULL` to both UPDATE branches so a revoked row stays revoked until an explicit admin re-grant. New INSERT rows are unaffected because the WHERE clause only gates the ON CONFLICT branch. #BUNYIP-261
fix(oidc): server-side intersect consent scopes; refuse to revive revoked entitlement rows
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 54s
Check / fmt + clippy + build + tests (pull_request) Successful in 10m9s
Create release / Create release from merged PR (pull_request) Has been skipped
71831f9661
BUNYIP-261: two of the consent-flow holes the 2026-06-30 audit named.

Scope smuggling. `POST /v1/users/me/consents` previously wrote `body.scopes` directly into `user_application_access.granted_scopes` without checking the request against the client's registered `allowed_scopes`. The consent screen took the `missing` set from the URL query, the user clicked Allow, and any scope name in that body landed forever. Combined with bunyip-web's consent.rs echoing the URL `missing` param verbatim into the hidden form field, a phishing link with `?missing=admin.god` could permanently grant a fake scope for any client. The audit called this high-severity.

Now: the handler loads the client and intersects body.scopes with `client.allowed_scopes` before calling `add_scopes_to_grant`. A request whose scopes ALL fall outside the allowlist returns 400 ("no requested scope is in this client's allowed_scopes") so the caller's intent isn't silently downgraded to a no-op. Unknown or disabled client_id falls through to 400. The structured log line on the audit / debug path now records both `requested` and `granted` so the BUNYIP-234 consent-loop investigation can tell when an upstream caller asked for more than they got.

The audit's larger fix (a signed consent_ticket that closes the URL-param trust chain entirely) is deferred to a follow-up; rewriting bunyip-web's consent UI + adding the ticket table is heavier than this PR's scope. The intersection check closes the immediate exploit even without the ticket rework.

Refuse to revive revoked rows. `grant_entitlement` and `add_scopes_to_grant` both cleared `revoked_at` via `ON CONFLICT DO UPDATE`. That made the JIT baseline path at the next /authorize silently re-grant any entitlement an admin had explicitly revoked, and any subsequent user-driven "Allow" click would undo an admin's revocation. Add `WHERE user_application_access.revoked_at IS NULL` to both UPDATE branches so a revoked row stays revoked until an explicit admin re-grant. New INSERT rows are unaffected because the WHERE clause only gates the ON CONFLICT branch.

#BUNYIP-261
YousifShkara deleted branch fix/BUNYIP-261-consent-scope-smuggling 2026-06-30 06:42:45 +02:00
Sign in to join this conversation.
No reviewers
No labels
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
psa-systems/bunyip!292
No description provided.