Skip to content

fix(mcp): strip scheme default port from get_request_base_url netloc - #32921

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4126_default_port_strip
Jul 11, 2026
Merged

fix(mcp): strip scheme default port from get_request_base_url netloc#32921
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4126_default_port_strip

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Supersedes #31771

Linear ticket

Resolves LIT-4126

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

CI note: 126 checks pass; the single red job is ci/circleci: batches_testing, which currently fails identically on every open PR against litellm_internal_staging (checked 8 open PRs), so it is unrelated to this change

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Rig: local proxy on port 4126 with general_settings.use_x_forwarded_for: true and mcp_trusted_proxy_ranges: ["127.0.0.0/8"], one oauth2 / authorization_code MCP server whose authorization_url / token_url / registration_url point at a stub IdP on 127.0.0.1:9310, PROXY_BASE_URL unset so the header reconstruction path is exercised. The load balancer is simulated by sending X-Forwarded-Proto: https, X-Forwarded-Host: litellm.example.com, X-Forwarded-Port: 443 from a trusted range; ingresses set that port header on every TLS request, which is exactly the customer topology that breaks

Before, on this branch's base (litellm_internal_staging 80c5217), the port leaks into every emitted URL:

$ curl -s http://localhost:4126/.well-known/oauth-authorization-server \
    -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: litellm.example.com" -H "X-Forwarded-Port: 443" \
    | python3 -m json.tool | head -5
{
    "issuer": "https://litellm.example.com:443",
    "authorization_endpoint": "https://litellm.example.com:443/v1/mcp/oauth/authorize",
    "token_endpoint": "https://litellm.example.com:443/v1/mcp/oauth/token",

$ curl -s -D - -o /dev/null "http://localhost:4126/stubidp/authorize?client_id=stub-client&redirect_uri=http://127.0.0.1:6274/callback&state=xyz&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" \
    -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: litellm.example.com" -H "X-Forwarded-Port: 443" | grep -i ^location
location: http://127.0.0.1:9310/authorize?client_id=stub-client&redirect_uri=https%3A%2F%2Flitellm.example.com%3A443%2Fcallback&state=lCQVxUntM1Y_4Ay6d-NYFjBjNnxdkI54YtwpnCfUylk&response_type=code&scope=openid+mcp&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256

$ curl -s -X POST http://localhost:4126/stubidp/register -H "Content-Type: application/json" \
    -H "X-Forwarded-Proto: https" -H "X-Forwarded-Host: litellm.example.com" -H "X-Forwarded-Port: 443" \
    -d '{"redirect_uris":["http://127.0.0.1:6274/callback"],"client_name":"test-client"}' | python3 -m json.tool | grep -A2 redirect_uris
    "redirect_uris": [
        "https://litellm.example.com:443/callback"
    ]

The second command is the customer visible failure: the redirect_uri handed to the upstream IdP is https://litellm.example.com:443/callback, and providers that store the registered URI canonically and compare per RFC 6749 3.1.2.3 simple string comparison reject the flow with an invalid client or mismatched redirect_uri error

After, with this PR's commit, the same curls emit canonical URLs:

issuer                "https://litellm.example.com"
authorize Location    ...redirect_uri=https%3A%2F%2Flitellm.example.com%2Fcallback&...
DCR redirect_uris     ["https://litellm.example.com/callback"]

Edge cases on the fixed proxy: X-Forwarded-Port: 8443 stays https://litellm.example.com:8443; X-Forwarded-Proto: http with port 80 becomes http://litellm.example.com; a port embedded directly in X-Forwarded-Host: litellm.example.com:443 is also stripped; with no forwarding headers the local base http://localhost:4126 is returned unchanged

Type

Bug Fix

Changes

get_request_base_url in oauth_utils.py rebuilds the public base URL from X-Forwarded-* headers sent by a trusted load balancer and appends X-Forwarded-Port to the netloc without eliding the scheme default port, so X-Forwarded-Port: 443 (which ingresses send routinely) yields https://host:443. RFC 3986 6.2.3 treats an explicit default port as equivalent to none, and producers should omit it per 3.2.3. The module already has _strip_default_port and already applies it on the comparison side (validate_trusted_redirect_uri normalizes both netlocs before the same origin check), so validation tolerated :443 while emission leaked it; this change applies the same helper to the returned netloc, making emission consistent with validation. Every emitter funnels through this function (authorize redirect_uri, token exchange redirect_uri, DCR redirect_uris, protected resource and authorization server metadata, WWW-Authenticate challenge URLs, the MCP registry), so the single call site covers them all

Five cases added to test_get_request_base_url_comprehensive: :443 with X-Forwarded-Host stripped, :80 with http stripped, :443 on a portless base without X-Forwarded-Host stripped, non default :8443 preserved, and :443 embedded directly in X-Forwarded-Host stripped. On the unfixed base the four stripping cases fail and the preservation case passes; with the fix the full mapped file is 135 passed

Supersedes #31771, which contains the same one line fix; reopened here to land it under an internal branch with the live proof of fix attached and one extra regression case (X-Forwarded-Host carrying the port itself). Operators who cannot upgrade can set PROXY_BASE_URL to their canonical origin as a workaround; it takes priority over header reconstruction

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes get_request_base_url in oauth_utils.py so that scheme-default ports (:443 for HTTPS, :80 for HTTP) are stripped from emitted OAuth URLs when a trusted load balancer supplies X-Forwarded-Port. The existing _strip_default_port helper was already applied on the validation/comparison side but not during URL construction, causing the emitted redirect_uri, metadata endpoints, and other OAuth URLs to include the redundant port.

  • oauth_utils.py: One-line change wraps the netloc passed to urlunparse in _strip_default_port(scheme, netloc), bringing emission in line with the normalization already used in validate_trusted_redirect_uri.
  • test_discoverable_endpoints.py: Five new parametrized cases cover :443 via X-Forwarded-Port, :80 for HTTP, :443 with no X-Forwarded-Host, preserved non-default :8443, and :443 embedded directly in X-Forwarded-Host; all are mock-only with the trust gate patched.

Confidence Score: 5/5

Safe to merge — the change is a one-line, well-scoped addition of an already-tested helper to the URL emission path, with five new mock test cases that would have caught the regression on the unfixed base.

The fix correctly applies an existing normalisation helper (_strip_default_port) to the construction path that previously skipped it, making emission consistent with the validation path that already called the same helper. The five new cases directly exercise the four stripping scenarios and one preservation scenario described in the PR description. No production logic is modified beyond the single call site.

No files require special attention — both changed files are straightforward and well-covered by the new test cases.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/oauth_utils.py Single-line fix: applies the existing _strip_default_port helper to the netloc in get_request_base_url so that scheme-default ports (:443 for HTTPS, :80 for HTTP) are omitted from emitted URLs, consistent with how the validation path already normalized netlocs.
tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py Five new parametrized cases added to test_get_request_base_url_comprehensive: stripping :443 via X-Forwarded-Port, stripping :80 for HTTP, stripping :443 when no X-Forwarded-Host is set, preserving non-default :8443, and stripping :443 already embedded in X-Forwarded-Host. All cases are mock-only (no real network calls).

Reviews (1): Last reviewed commit: "fix(mcp): strip scheme default port from..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4126_default_port_strip (168bc38) with litellm_internal_staging (80c5217)

Open in CodSpeed

@tin-berri
tin-berri merged commit 9659ae2 into litellm_internal_staging Jul 11, 2026
128 of 129 checks passed
@tin-berri
tin-berri deleted the litellm_lit4126_default_port_strip branch July 11, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants