domain: consent scope-clamp is vacuous — tampered consent form can widen OAuth grant #48
Labels
No labels
kind
bug
kind
enhancement
wayfinder
grilling
wayfinder
map
wayfinder
prototype
wayfinder
research
wayfinder
task
workflow
needs-info
workflow
needs-triage
workflow
ready-for-agent
workflow
ready-for-human
workflow
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rosa/vernier#48
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 during a code review of the
domaincrate.Location:
crates/domain/src/services.rs:788(complete_authorization)Severity: Security — OAuth grant integrity / PKCE bypass
Problem
complete_authorizationclamps granted scopes againstconsent.requested, but the web handler rebuildsrequested(and the PKCEcode_challenge, viaCodeChallenge::from_storage, which skips S256 validation) from client-controlled hidden form fields. Becauserequestedis itself attacker-controlled, the clamp is vacuous: a tamperedPOST /auth/consentcan mint a code carrying scopes the client never requested, plus an arbitrary or empty code challenge.Failure scenario
A client requests
scope=createatGET /auth. The signed-in user edits the hiddenrequested_scopefield tocreate update delete(and an arbitrarycode_challenge) before submittingPOST /auth/consent.Scopes::parse_lenientaccepts it, the clamp passes all three scopes, andissue_authorization_codemints a code grantingupdate+deletethat no validatedAuthorizationRequestever authorized.Suggested fix
Re-derive
requestedandcode_challengefrom a server-side-stored, validated authorization request keyed by an opaque id, rather than trusting the consent form. Route the challenge throughCodeChallenge::parseso S256 validation is enforced.Agent Brief
Category: bug
Summary: Bind consent to the server-side validated authorization request so a tampered consent form cannot widen the OAuth grant or smuggle an unvalidated PKCE challenge
Verification: Confirmed by code inspection during triage. The consent POST handler rebuilds
AuthorizationConsententirely from hidden form fields:requestedviaScopes::parse_lenientand the challenge viaCodeChallenge::from_storage(a storage-rehydration constructor that performs no validation). The scope clamp inAppService::complete_authorizationfiltersselectedagainst that same form-derivedrequested, so it cannot narrow anything an attacker widened. Severity qualifier: the CSRF origin-checking layer on the router blocks cross-site form submission, so the practical attacker is the signed-in user tampering with their own consent form — a grant-integrity / least-privilege violation (and a bypass of ADR-0008's "PKCE validated before any code is issued" contract), not a remote account takeover.Scopes::parse_lenientonly admits known scopes, so widening is bounded to the defined scope set.Current behavior:
The
GET /authauthorization endpoint validates the request withAuthorizationRequest::parse(S256-only PKCE, unknown scopes dropped, redirect-URI/client trust) but uses the result only to render the consent page; nothing validated is retained server-side. The requested scopes, code challenge, client id, redirect URI, and state round-trip through hidden fields on the consent form.POST /auth/consenttrusts those fields, so a user who editsrequested_scopebefore submitting gets an authorization code minted for scopes no validatedAuthorizationRequestever carried, and an editedcode_challengereaches code issuance without ever passingCodeChallenge::parse.Desired behavior:
When
GET /authvalidates anAuthorizationRequest, the validated request is stored server-side in the user's session, keyed by an opaque, unguessable id (this design choice was pinned during triage: session storage, matching the existing pattern that parks a pending authorization request across the login redirect — no database schema change). The consent form carries only that opaque id, the scope checkboxes the user left checked, and the approve/deny action.POST /auth/consentlooks up the stored request by id and re-derives client id, redirect URI, state,code_challenge, andrequestedscopes from it; the form contributes nothing but the id, the selected subset, and the decision. A consent POST whose id doesn't resolve (missing, expired session, already consumed) is rejected as a bad request without redirecting to any client-supplied URI. The stored request should be consumed by the consent decision so it cannot be replayed. The existing clamp incomplete_authorizationthen does its job against a trustworthyrequested.Key interfaces:
AuthorizationConsent— should be constructible only from a server-side validated request plus the user's selection/decision, not from raw form strings. Consider whether the type itself can enforce this (e.g. built from anAuthorizationRequestrather than parallel parsed fields).AuthorizationRequest— needs to be storable in and retrievable from the session (serializable), or accompanied by a small session-storable representation that preserves its validated invariants.CodeChallenge::from_storage— after this change it must have no callers on user input; its only legitimate use is rehydrating already-validated values from the repository layer. If nothing outside the repository uses it, restrict its visibility accordingly.requested_scope,code_challenge,client_id,redirect_uri, andstatefields disappear from the template.Acceptance criteria:
GET /authrequest asked for results in a code granting at most the validated request's scopes (test at the web-handler level, not just the domain clamp).code_challengebound to the issued code; the challenge always comes from the validated request and only ever enters the system throughCodeChallenge::parse.cimise task passes.Out of scope:
redeem_authorization_code) or the token endpoint — the bindings checked at redemption are already correct.