fix(security): reject consent scopes outside the original request

The /consent POST handler trusted the scope values submitted in the form,
so a forged consent submission could approve (and persist consent for)
scopes that were never part of the originating authorization request —
a scope-escalation vector.

Intersect the submitted scopes with the originally requested set stored in
the session before saving consent and completing the flow.

Refs: porchlight-a03

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-04 10:26:55 +02:00
parent c52778326e
commit cdde3e3754
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
2 changed files with 28 additions and 2 deletions

View file

@ -338,8 +338,10 @@ async def consent_submit(request: Request) -> Response:
return RedirectResponse(f"{redirect_uri}?{params}", status_code=303)
return HTMLResponse("<h1>Error</h1><p>Invalid action</p>", status_code=400)
# Allow — collect approved scopes
approved_scopes: list[str] = [str(s) for s in form.getlist("scope")]
# Allow — collect approved scopes, rejecting anything outside the
# originally requested set (a forged form must not escalate scope).
requested_scopes = set(auth_params.get("scope", "openid").split())
approved_scopes: list[str] = [str(s) for s in form.getlist("scope") if str(s) in requested_scopes]
if "openid" not in approved_scopes:
approved_scopes = ["openid", *list(approved_scopes)]

View file

@ -223,6 +223,30 @@ async def test_partial_consent_filters_scopes(client: AsyncClient) -> None:
assert set(consent.scopes) == {"openid", "profile"}
async def test_consent_rejects_scopes_outside_request(client: AsyncClient) -> None:
"""A forged consent POST cannot approve scopes that were not requested."""
app = client._transport.app # type: ignore[union-attr]
_register_test_rp(app)
await _create_test_user(app)
# Only openid + profile were requested; "email" is forged into the POST.
await _login_and_start_auth(client, scope="openid profile")
token = await get_csrf_token(client)
res = await client.post(
"/consent",
data={"action": "allow", "scope": ["openid", "profile", "email"]},
headers={"X-CSRF-Token": token},
follow_redirects=False,
)
assert res.status_code == 303
consent_repo = app.state.consent_repo
consent = await consent_repo.get_consent("lusab-consent", "consent-rp")
assert consent is not None
assert "email" not in consent.scopes
assert set(consent.scopes) == {"openid", "profile"}
# -- Test helpers --