Skip to content

fix: resolve OAuth "Fetch Tools from MCP Server" button not triggering tool discovery (#4815)#4841

Merged
brian-hussey merged 3 commits into
mainfrom
fix/issue-4815-oauth-fetch-tools-csrf
May 24, 2026
Merged

fix: resolve OAuth "Fetch Tools from MCP Server" button not triggering tool discovery (#4815)#4841
brian-hussey merged 3 commits into
mainfrom
fix/issue-4815-oauth-fetch-tools-csrf

Conversation

@MohanLaksh

@MohanLaksh MohanLaksh commented May 20, 2026

Copy link
Copy Markdown
Collaborator

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:

curl -X POST "<base-url>/oauth/fetch-tools/{gateway_id}" \
  -H "Authorization: Bearer <admin-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 — The enforce_fetch_tools_csrf dependency validates the Origin/Referer header against settings.app_domain, which defaults to http://localhost:4444. In production, the browser sends Origin: https://actual-production-domain.com, which does not match http://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 — The oauth_callback handler returned a bare HTMLResponse(content=...) without ever calling _set_admin_csrf_cookie(). The inline JavaScript reads the CSRF token from document.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.cookie returns an empty string. This sends X-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 calls fetch() with { method: "POST", credentials: "include" } but omits the X-CSRF-Token header 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-normalized request.url) to the allowed origins set. Both ProxyHeadersMiddleware and ForwardedHostMiddleware run before route handlers and correctly normalize request.url.scheme and request.url.netloc from X-Forwarded-Proto/X-Forwarded-Host headers. This means request.url already reflects the public-facing origin by the time enforce_fetch_tools_csrf executes.

request_origin = f"{request.url.scheme}://{request.url.netloc}"
allowed = {app_origin, request_origin}

Fix 2 — Set CSRF cookie on callback response

Changed the callback from bare return HTMLResponse(content=...) to a response-variable pattern:

response = HTMLResponse(content=html_content)
response.set_cookie(
    key=ADMIN_CSRF_COOKIE_NAME,
    value=csrf_token,
    max_age=max_age,
    path=root_path or "/",
    httponly=False,
    secure=use_secure,
    samesite="strict",
)
return response

Token generation mirrors admin.py:_set_admin_csrf_cookie — reuses existing valid token from request.cookies or generates a new one with secrets.token_urlsafe(32).

Additional defensive improvements to the inline script:

  • Wrapped in try/catch with console.error for CSP-blocked script diagnosability
  • Added DOM element existence checks with early return
  • Removed misleading Content-Type: application/json header (no request body)
  • Tokens inlined directly as JS literal instead of reading from document.cookie

Fix 3 — Use getAuthHeaders() in fetchToolsForGateway

Replaced the bare fetch call with getAuthHeaders(false), which:

  • Adds X-CSRF-Token header from the CSRF cookie (satisfies double-submit cookie check)
  • Optionally adds Authorization: Bearer <token> if getAuthToken() finds a readable JWT (bypasses CSRF entirely at line 60-63)
const headers = await getAuthHeaders(false);
const response = await fetch(
  `${window.ROOT_PATH}/oauth/fetch-tools/${gatewayId}`,
  { method: "POST", credentials: "include", headers },
);

Files Changed

File Change Lines
mcpgateway/routers/oauth_router.py Origin check + CSRF cookie on callback + defensive JS + remove misleading Content-Type +123, -51
mcpgateway/admin_ui/auth.js Use getAuthHeaders(false) in fetchToolsForGateway +3, -1
tests/unit/js/auth.test.js Mocked getCookie/getAuthToken + assert X-CSRF-Token in fetch +18, -0
tests/unit/mcpgateway/routers/test_oauth_router.py RC1 origin test + CSRF cookie set/reuse tests + pragma allowlist +136, -2

Verification

  • Python tests: 18,693 passed, 596 skipped
  • JS tests: 67 passed
  • Doctest: 1,175 passed
  • lint-web: 0 errors, 0 vulnerabilities
  • Bandit: 0 issues
  • Pylint: 10.00/10
  • Ruff: 0 issues
  • Package verification: 10/10

Closes #4815

@MohanLaksh MohanLaksh added bug Something isn't working triage Issues / Features awaiting triage labels May 20, 2026
@MohanLaksh
MohanLaksh force-pushed the fix/issue-4815-oauth-fetch-tools-csrf branch 3 times, most recently from 4d06493 to 38875e3 Compare May 20, 2026 18:40
@MohanLaksh MohanLaksh added the ica ICA related issues label May 20, 2026

@Lang-Akshay Lang-Akshay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
MohanLaksh force-pushed the fix/issue-4815-oauth-fetch-tools-csrf branch from 2807edb to af629d6 Compare May 21, 2026 11:22
@MohanLaksh

MohanLaksh commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

@Lang-Akshay, @brian-hussey ,

I have incorporated all of the review suggestions. Please check and approve.

@brian-hussey
brian-hussey requested a review from Lang-Akshay May 22, 2026 18:16
Lang-Akshay
Lang-Akshay previously approved these changes May 23, 2026

@Lang-Akshay Lang-Akshay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

MohanLaksh and others added 3 commits May 24, 2026 15:27
…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
brian-hussey force-pushed the fix/issue-4815-oauth-fetch-tools-csrf branch from 0791719 to fb0ffc3 Compare May 24, 2026 14:36
@brian-hussey brian-hussey self-assigned this May 24, 2026
@brian-hussey
brian-hussey merged commit e41f9e6 into main May 24, 2026
30 checks passed
@brian-hussey
brian-hussey deleted the fix/issue-4815-oauth-fetch-tools-csrf branch May 24, 2026 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ica ICA related issues triage Issues / Features awaiting triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][UI]: "Fetch Tools from MCP Server" button on OAuth success page does not trigger tool discovery

3 participants