fix: resolve OAuth "Fetch Tools from MCP Server" button not triggering tool discovery (#4815)#4841
Merged
Merged
Conversation
MohanLaksh
requested review from
Lang-Akshay,
brian-hussey,
crivetimihai,
ja8zyjits,
kevalmahajan and
madhav165
as code owners
May 20, 2026 18:04
MohanLaksh
force-pushed
the
fix/issue-4815-oauth-fetch-tools-csrf
branch
3 times, most recently
from
May 20, 2026 18:40
4d06493 to
38875e3
Compare
Lang-Akshay
requested changes
May 21, 2026
Lang-Akshay
left a comment
Collaborator
There was a problem hiding this comment.
Thanks for the PR @MohanLaksh . Very important fix.
Please address following 4-findings.
The three targeted fixes are well-conceived and address real failure modes. However, two issues in the implementation weaken defense-in-depth controls and one is a latent XSS vector.
| # | File | Line | Severity | CWE | Description |
|---|---|---|---|---|---|
| 1 | mcpgateway/routers/oauth_router.py |
83 | Medium | CWE-346 | X-Forwarded-Host origin amplification. request_origin is derived from request.url, which ForwardedHostMiddleware rewrites unconditionally from the X-Forwarded-Host request header. It is then added to the CSRF allowed set. Without a trusted-proxy IP allowlist, an attacker can send X-Forwarded-Host: evil.com + Origin: https://evil.com and pass the origin check. The fix was intended for the case where app_domain is left at the localhost default, but it inadvertently gives any caller that controls X-Forwarded-Host a bypass. Fix: only add request_origin when parsed_app.hostname resolves to a loopback address (localhost, 127.0.0.1, ::1), or enforce that APP_DOMAIN must be set in non-development environments and remove the fallback entirely. |
| 2 | mcpgateway/routers/oauth_router.py |
668–670 | Medium | CWE-79 | Raw CSRF token interpolated into JS literal. The existing cookie value is embedded directly into a Python f-string as a JS single-quoted string literal ('X-CSRF-Token': '{csrf_token}'). A cookie value containing ', \, or </script> can break out of the literal. A value such as '; alert(1); var x=' would produce stored XSS on the success page (exploitable via subdomain cookie injection, where the attacker can write an arbitrary cookie). Fix: replace the f-string interpolation with json.dumps(csrf_token) and add a regex guard (^[A-Za-z0-9_=-]{32,}$) when re-using an existing cookie value so only secrets.token_urlsafe-safe characters are accepted. |
| 3 | mcpgateway/routers/oauth_router.py |
789 | Low | CWE-613 | Wrong settings field for CSRF cookie lifetime. max_age is computed as max(300, int(getattr(settings, "token_expiry", 60)) * 60) — token_expiry is the session JWT expiry in minutes; the semantically correct field for CSRF cookie lifetime is settings.csrf_token_expiry (in seconds, default 3600). At the maximum token_expiry of 1440 min the CSRF cookie would live 86 400 s (24 h), bypassing the intended csrf_token_expiry limit. Fix: replace with max(300, settings.csrf_token_expiry). |
| 4 | tests/unit/mcpgateway/routers/test_oauth_router.py |
184+ | Info | — | Deny-path gap: no test for X-Forwarded-Host injection. The new test_pass_with_request_origin_when_app_domain_does_not_match tests the allow path only. No test verifies that when app_domain is correctly configured, a mismatched request.url.netloc (e.g. attacker-controlled via X-Forwarded-Host) still results in 403. Fix: add a test that sets settings.app_domain = "https://gateway.example.com" and request.url.netloc = "evil.example.com" and asserts 403. |
MohanLaksh
added a commit
that referenced
this pull request
May 21, 2026
- Fix 1 (CWE-346): guard request_origin behind loopback check to prevent X-Forwarded-Host origin amplification - Fix 2 (CWE-79): use json.dumps() + regex guard on CSRF token to prevent XSS via subdomain cookie injection - Fix 3 (CWE-613): use csrf_token_expiry instead of token_expiry for CSRF cookie max_age - Fix 4: add deny-path test for X-Forwarded-Host injection Closes #4841 Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
MohanLaksh
force-pushed
the
fix/issue-4815-oauth-fetch-tools-csrf
branch
from
May 21, 2026 11:22
2807edb to
af629d6
Compare
Collaborator
Author
|
I have incorporated all of the review suggestions. Please check and approve. |
Lang-Akshay
previously approved these changes
May 23, 2026
Lang-Akshay
left a comment
Collaborator
There was a problem hiding this comment.
Thanks for the PR @MohanLaksh
All 4 findings resolved. Approving this PR.
One Minor Note (Non-blocking)
- Test line 368: assert "Secure" not in set_cookie or "HttpOnly" not in set_cookie — this assertion is weaker than needed; it passes even when both flags are absent.
Should be assert "HttpOnly" not in set_cookie to precisely verify JS readability. Not blocking.
…g tool discovery (#4815) Three root causes addressed: 1. Same-origin CSRF check failed in production — enforce_fetch_tools_csrf originated from settings.app_domain (default "http://localhost:4444") only. Added request_origin derived from the already-normalized request.url (ProxyHeadersMiddleware + ForwardedHostMiddleware run before route handlers), so production Origin headers match. 2. Callback success page never set the mcpgateway_csrf_token cookie — the inline JS read an empty token from document.cookie, sent an empty X-CSRF-Token header, and got 403 from the double-submit check. Now generates/reuses a CSRF token, sets it on the HTMLResponse with the same parameters as admin.py:_set_admin_csrf_cookie. 3. Admin panel fetchToolsForGateway (auth.js) used bare fetch() without the X-CSRF-Token header. Replaced with getAuthHeaders(false) which also picks up a Bearer token if available (bypasses CSRF entirely). Additional safety: wrapped inline script in try/catch with console.error for diagnosability when CSP blocks the script, added DOM null-guards, and wrapped response.json() in a try/catch for non-JSON error responses. Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
- Fix 1 (CWE-346): guard request_origin behind loopback check to prevent X-Forwarded-Host origin amplification - Fix 2 (CWE-79): use json.dumps() + regex guard on CSRF token to prevent XSS via subdomain cookie injection - Fix 3 (CWE-613): use csrf_token_expiry instead of token_expiry for CSRF cookie max_age - Fix 4: add deny-path test for X-Forwarded-Host injection Closes #4841 Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
Signed-off-by: Brian Hussey <brian.hussey@ie.ibm.com>
brian-hussey
force-pushed
the
fix/issue-4815-oauth-fetch-tools-csrf
branch
from
May 24, 2026 14:36
0791719 to
fb0ffc3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
After completing a successful OAuth Authorization Code flow on a gateway, the success page displays a "Fetch Tools from MCP Server" button. Clicking this button does not populate tools for the gateway. The gateways toolCount remains 0 and no tools appear in the Admin UI tools list. The same operation succeeds when called directly via API with a Bearer token:
Root Cause Analysis
Three compounding failure modes were identified:
Root Cause 1 — Same-origin CSRF check fails in production
mcpgateway/routers/oauth_router.py:81-87— Theenforce_fetch_tools_csrfdependency validates the Origin/Referer header againstsettings.app_domain, which defaults tohttp://localhost:4444. In production, the browser sendsOrigin: https://actual-production-domain.com, which does not matchhttp://localhost:4444, causing a 403 response. The workaround works because Bearer token auth bypasses this check entirely (line 60-63).Root Cause 2 — Callback response never sets CSRF cookie
mcpgateway/routers/oauth_router.py:660-759— Theoauth_callbackhandler returned a bareHTMLResponse(content=...)without ever calling_set_admin_csrf_cookie(). The inline JavaScript reads the CSRF token fromdocument.cookie, but if the cookie expired during the IdP redirect (1-hour default max_age) or the user is in a private browsing session with no prior admin session,document.cookiereturns an empty string. This sendsX-CSRF-Token: ""to the server, which fails the double-submit cookie check at line 92.Root Cause 3 — Admin panel fetchToolsForGateway missing CSRF header
mcpgateway/admin_ui/auth.js:411-415— The admin panel version of the "Fetch Tools" button callsfetch()with{ method: "POST", credentials: "include" }but omits theX-CSRF-Tokenheader entirely. For cookie-authenticated admin sessions (no Bearer token), this also fails the double-submit cookie check with 403. The Bearer bypass at line 60-63 never triggers because no Authorization header is sent.Solution
Fix 1 — Self-referential origin check
Added
request_origin(derived from the already-normalizedrequest.url) to the allowed origins set. BothProxyHeadersMiddlewareandForwardedHostMiddlewarerun before route handlers and correctly normalizerequest.url.schemeandrequest.url.netlocfromX-Forwarded-Proto/X-Forwarded-Hostheaders. This meansrequest.urlalready reflects the public-facing origin by the timeenforce_fetch_tools_csrfexecutes.Fix 2 — Set CSRF cookie on callback response
Changed the callback from bare
return HTMLResponse(content=...)to a response-variable pattern:Token generation mirrors
admin.py:_set_admin_csrf_cookie— reuses existing valid token fromrequest.cookiesor generates a new one withsecrets.token_urlsafe(32).Additional defensive improvements to the inline script:
console.errorfor CSP-blocked script diagnosabilityContent-Type: application/jsonheader (no request body)document.cookieFix 3 — Use getAuthHeaders() in fetchToolsForGateway
Replaced the bare fetch call with
getAuthHeaders(false), which:X-CSRF-Tokenheader from the CSRF cookie (satisfies double-submit cookie check)Authorization: Bearer <token>ifgetAuthToken()finds a readable JWT (bypasses CSRF entirely at line 60-63)Files Changed
mcpgateway/routers/oauth_router.pymcpgateway/admin_ui/auth.jsgetAuthHeaders(false)infetchToolsForGatewaytests/unit/js/auth.test.jsgetCookie/getAuthToken+ assert X-CSRF-Token in fetchtests/unit/mcpgateway/routers/test_oauth_router.pyVerification
Closes #4815