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:
parent
c52778326e
commit
cdde3e3754
2 changed files with 28 additions and 2 deletions
|
|
@ -338,8 +338,10 @@ async def consent_submit(request: Request) -> Response:
|
||||||
return RedirectResponse(f"{redirect_uri}?{params}", status_code=303)
|
return RedirectResponse(f"{redirect_uri}?{params}", status_code=303)
|
||||||
return HTMLResponse("<h1>Error</h1><p>Invalid action</p>", status_code=400)
|
return HTMLResponse("<h1>Error</h1><p>Invalid action</p>", status_code=400)
|
||||||
|
|
||||||
# Allow — collect approved scopes
|
# Allow — collect approved scopes, rejecting anything outside the
|
||||||
approved_scopes: list[str] = [str(s) for s in form.getlist("scope")]
|
# 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:
|
if "openid" not in approved_scopes:
|
||||||
approved_scopes = ["openid", *list(approved_scopes)]
|
approved_scopes = ["openid", *list(approved_scopes)]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,30 @@ async def test_partial_consent_filters_scopes(client: AsyncClient) -> None:
|
||||||
assert set(consent.scopes) == {"openid", "profile"}
|
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 --
|
# -- Test helpers --
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue