From cdde3e3754bff84445988ae1fadeb8e981c2358e Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 4 Jun 2026 10:26:55 +0200 Subject: [PATCH] fix(security): reject consent scopes outside the original request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/porchlight/oidc/endpoints.py | 6 ++++-- tests/test_oidc/test_consent_flow.py | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/porchlight/oidc/endpoints.py b/src/porchlight/oidc/endpoints.py index 67d4968..8bfca41 100644 --- a/src/porchlight/oidc/endpoints.py +++ b/src/porchlight/oidc/endpoints.py @@ -338,8 +338,10 @@ async def consent_submit(request: Request) -> Response: return RedirectResponse(f"{redirect_uri}?{params}", status_code=303) return HTMLResponse("

Error

Invalid action

", 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)] diff --git a/tests/test_oidc/test_consent_flow.py b/tests/test_oidc/test_consent_flow.py index 91d3d73..9b40006 100644 --- a/tests/test_oidc/test_consent_flow.py +++ b/tests/test_oidc/test_consent_flow.py @@ -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 --