feat: add post-OAuth redirect allowlist (redirect_uri_after_oauth) - #6411
feat: add post-OAuth redirect allowlist (redirect_uri_after_oauth)#6411cafalchio wants to merge 9 commits into
Conversation
jonpspri
left a comment
There was a problem hiding this comment.
Review: post-OAuth redirect allowlist (redirect_uri_after_oauth)
Thanks for tackling this — the security engineering here is genuinely strong: fail-closed validation at three layers, a startup-validated exact-origin allowlist, and deny-path tests covering the nasty bypass classes (network-path references, backslash normalization, credentials-in-URL, malformed ports). I reviewed the full diff against main, read issue #6309, and ran the new/affected tests against the PR tree — all green (87 focused tests across utils/config/schemas/router, plus the 38 admin CSRF/assembly tests). Findings below, categorized.
Blocking (scope reconciliation)
The PR implements a different mechanism than issue #6309 specifies, and never says so. The issue asks for a per-flow post_login_redirect_uri passed when the flow is initiated (GET /oauth/authorize?...&post_login_redirect_uri=..) and encoded into state, with acceptance criteria written against that flow. This PR delivers a static, per-gateway admin config gated by a single global OAUTH_REDIRECT_ALLOWED_ORIGIN.
To be clear, I think the chosen design is defensible — arguably safer than the issue's sketch, since a static allowlist has no per-request open-redirect surface at all. It also does meet the reporter's underlying need ("bring the user back to our own UI") for the common single-app case. But the per-flow acceptance criteria are literally unimplemented, multi-app deployments are capped at one external origin per gateway deployment, and the PR body claims "Closes #6309" without acknowledging the divergence. Worth an explicit note in the PR description and a comment on the issue confirming the reporter accepts static-per-gateway config as the resolution (or scoping this PR as partial).
Functionally impacting
- No Admin UI field.
admin.htmlcarriesoauth_redirect_uriinputs in four places, butoauth_redirect_uri_after_oauthappears nowhere in templates oradmin_ui/. The feature is REST-API-only, and the new branch in_assemble_oauth_config_from_fieldsis unreachable from the actual UI. Either add the input or document the feature as API-only. - A2A path accepts the field inertly. The shared assembler also serves A2A agent create/edit, so A2A
oauth_configcan now carryredirect_uri_after_oauth, which the A2A flow never consumes. Harmless, but confusing — worth gating or a comment. - No documentation. Zero references in
docs/for the new env var or the newoauth_configkey; the PR checklist leaves docs unchecked. A short section in the OAuth management docs would save operators real guesswork, especially around the single-global-origin limitation.
Security assessment — no blocking findings
The threat model (open redirect, token leakage, state burning) is well handled:
- Fail-closed at three layers: Pydantic schema (both
GatewayCreateandGatewayUpdate), the admin form assembler, and again at redirect time — the last covering legacy rows written before validation existed. Redirect-time validation runs beforecomplete_authorization_code_flow, so a bad config doesn't burn the one-timestate(nice that a test asserts the exchange is never awaited). - Allowlist entry validated at startup as an exact HTTPS origin; wildcards, HTTP, paths, query, credentials all rejected.
- No tokens in the redirect URL;
Referrer-Policy: no-referrerprevents leaking the consumedcode/stateto the external origin; the redirect response carries no gateway-scoped cookies (the test asserting zeroset-cookieheaders is a good guard). - Popup mode cleanly excluded, with a regression test asserting no
Locationheader.
One maintenance risk worth noting (not a vulnerability today): the validation predicate and its error message are copy-pasted in three places, so a future hardening fix applied in one spot could silently miss another — see suggestions.
Suggestions (refactoring opportunities)
The extraction of _normalize_origin_parts from admin.py into a shared mcpgateway/utils/origin.py is a good move — a few follow-throughs in the same spirit:
- Triplicated validation + error string. The identical
is_allowed_redirect(...)check and verbatim f-string message appear inschemas.py,admin.py, andoauth_router.py. The router's_validate_post_oauth_redirectis already the right shape — hoisting onevalidate_post_oauth_redirect(url)helper intoorigin.py(callers wrap intoValueError/OAuthError) removes drift risk on a security control. origin_from_urlduplicatesderive_resource_origin.mcpgateway/utils/oauth_resource.pyalready has a well-documentedscheme://netlocextractor used by four call sites. One should delegate to the other — two origin-extraction helpers in adjacent utils is exactly the duplication this PR set out to kill.- Dead generality:
custom_redirect_after_callback(url, status_code)is only ever called with302. Dropping the parameter would simplify. - Wasted work in the callback: when the redirect fires, the code has already built the full success HTML page, a CSRF token, and a short-lived session JWT — all discarded. Short-circuiting right after token storage (when
redirect_uri_after_oauthis set and non-popup) would be clearer and avoid minting a throwaway JWT. - Follow-up, not this PR:
auth_middleware.pyandrbac.pystill carry their own inline same-origin Referer parsing;origin.pyis now the natural home for a future consolidation.
Minor notes
- Three of four commits are titled
secrets— DCO is present, but please squash/rename to a conventionalfeat:message before merge. - CI observation: the
pytestmatrix jobs show as skipped on this PR (and the check name renders as the unevaluatedpy${{ matrix.python }}, which looks like a workflow bug). The unit suite didn't run in CI; I ran the new/affected tests locally against the PR tree to compensate — all pass. - Good call including the
tests/live_gateway/black-box test for the 422 reject paths; happy-path redirect is covered by unit tests.
Summary: solid, security-first implementation of a reasonable design. Before merge: reconcile the design with #6309 explicitly (PR description + issue comment), add the Admin UI field or document API-only, add docs, consolidate the triplicated validation, and squash the commits. Happy to discuss any of these.
c11569f to
0762378
Compare
madhu-mohan-jaishankar
left a comment
There was a problem hiding this comment.
Any Admin-UI edit of an OAuth gateway silently wipes the new setting (mcpgateway/admin.py:13232, gateway_service.py:2956-2973)
The Admin UI field was deferred, so the edit form doesn't submit oauth_redirect_uri_after_oauth and update_gateway rebuilds oauth_config from the form fields. If an operator sets the redirect via the REST API and an admin later edits any unrelated field on that gateway in the UI, the setting is silently deleted with no error anywhere.
Fix: preserve the existing value when the form omits it (mirroring the client-secret preservation pattern), or add a hidden form field until the UI ships.
0762378 to
d9e0c5a
Compare
Review — cycle 2:
|
8b92537 to
124bd4f
Compare
|
@jonpspri There were a rebase issue.
|
3930fea to
6a55cfa
Compare
Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: cafalchio <mcafalchio@gmail.com>
Signed-off-by: cafalchio <mcafalchio@gmail.com>
8ec1594 to
86d4e57
Compare
Signed-off-by: cafalchio <mcafalchio@gmail.com>
Pull Request
🔗 Related Issue
Partially addresses #6309
📝 Summary
Adds
redirect_uri_after_oauth— an optional field in a gateway'soauth_configthat causes the browser to be sent to an operator-configured external URL after a successful Authorization Code OAuth callback, instead of landing on the gateway's built-in success page.{ "oauth_config": { "grant_type": "authorization_code", "redirect_uri_after_oauth": "https://app.example.com/oauth-complete" } }The destination must be within the single HTTPS origin declared by the new
OAUTH_REDIRECT_ALLOWED_ORIGINenvironment variable, which is validated at startup.Flow: register MCP OAuth → user logs in → callback →
302toredirect_uri_after_oauthIssue #6309 proposed a per-flow, dynamic mechanism: a
post_login_redirect_uriquery parameter passed atGET /oauth/authorize, encoded into the OAuthstate, and decoded on callback.This PR deliberately implements a static, per-gateway allowlist instead. The reason is security:
?post_login_redirect_uri=https://evil.com) would send the victim to an attacker-controlled page after authentication — a classic open-redirect / token-leakage vector documented in RFC 6819 §4.2.4.Trade-off: multi-app deployments are currently capped at one external origin per gateway deployment. This covers the common single-app case (the reporter's stated need: "bring the user back to our own UI"). The per-flow dynamic mechanism from the issue remains unimplemented and is out of scope for this PR.
Admin UI field: Adding the field to the Admin UI is intentionally deferred to a follow-up — the feature is fully usable via the REST API today.
What changed
mcpgateway/utils/origin.py(new) — shared origin utilities extracted fromadmin.py:normalize_origin_parts()— normalises scheme/host/port for exact same-origin comparison (moved fromadmin._normalize_origin_parts)origin_from_url()— extractsscheme://host[:port]from any URLis_same_origin()— absolute URL same-origin check with backslash rejectionis_exact_https_origin()— strict HTTPS-only origin validator (no path/query/fragment/credentials)is_allowed_redirect()— gate used at every enforcement pointmcpgateway/config.py— newoauth_redirect_allowed_originfield validated at startup byis_exact_https_origin(); rejects wildcards, HTTP, paths, credentials.mcpgateway/schemas.py—_validate_oauth_config_urlsenforcesredirect_uri_after_oauthmust be absolute and within allowed origins at schema validation time (Pydantic layer).mcpgateway/admin.py—_assemble_oauth_config_from_fieldsenforces the same check for the admin form path (non-Pydantic path)._normalize_origin_partsremoved in favour of the shared import.mcpgateway/routers/oauth_router.py—custom_redirect_after_callback()validates and issues the302redirect withReferrer-Policy: no-referrerafter a successful non-popup OAuth flow.Security invariants
complete_authorization_code_flow, so a misconfigured URL does not burn the one-time state./\\evil.example) rejected.user@host) rejected.is_popup=True.Referrer-Policy: no-referrerprevents leaking the consumed code/state to the external origin.📏 Reviewability
triage🏷️ Type of Change
✅ Checklist
make black isort pre-commit)