diff --git a/litellm/proxy/_experimental/mcp_server/oauth_utils.py b/litellm/proxy/_experimental/mcp_server/oauth_utils.py index 8541a691e88..09176f7253a 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth_utils.py +++ b/litellm/proxy/_experimental/mcp_server/oauth_utils.py @@ -29,6 +29,16 @@ _DEFAULT_PORTS = {"http": 80, "https": 443} # subdomain. HTTPS only. _TRUSTED_REDIRECT_ORIGINS_ENV = "MCP_TRUSTED_REDIRECT_ORIGINS" +# Comma-separated private-use URI allowlist for native MCP clients. +# A trailing ``*`` is a prefix match; end the prefix with ``/`` (e.g. +# ``myapp://host/oauth/*``) so ``.../oauth/callback*`` does not also +# match ``.../oauth/callback-2``. +_TRUSTED_NATIVE_REDIRECT_URIS_ENV = "MCP_TRUSTED_NATIVE_REDIRECT_URIS" + +# Default allowlist for trusted native redirect URIs. +_DEFAULT_NATIVE_REDIRECT_URIS: List[str] = [ + "cursor://anysphere.cursor-mcp/oauth/callback", +] _warned_invalid_proxy_base_url: Optional[str] = None @@ -212,10 +222,82 @@ def _matches_trusted_origin_entry(netloc: str, entry: str) -> bool: return netloc == entry +def _normalize_native_redirect_uri( + parsed, +) -> str: + """Lowercase scheme, netloc, and path for allowlist comparison.""" + return urlunparse( + ( + (parsed.scheme or "").lower(), + (parsed.netloc or "").lower(), + (parsed.path or "").lower(), + "", + "", + "", + ) + ) + + +def _parse_trusted_native_redirect_uris() -> List[str]: + """Built-in native MCP callbacks plus ``MCP_TRUSTED_NATIVE_REDIRECT_URIS``.""" + entries: List[str] = [uri.lower() for uri in _DEFAULT_NATIVE_REDIRECT_URIS] + raw = os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV, "").strip() + if not raw: + return entries + for token in raw.split(","): + entry = token.strip().lower() + if entry and entry not in entries: + entries.append(entry) + return entries + + +def _native_wildcard_prefix_matches(normalized: str, prefix: str) -> bool: + """Prefix match for ``entry*`` allowlist rows. + + When the prefix does not end with ``/``, only exact matches or + deeper path segments (``prefix/...``) are accepted — not siblings + like ``prefix-2``. + """ + if not normalized.startswith(prefix): + return False + suffix = normalized[len(prefix) :] + if not suffix: + return True + if prefix.endswith("/"): + return True + return suffix[0] == "/" + + +def _matches_trusted_native_redirect_uri(parsed) -> bool: + """Allowlisted private-use / custom-scheme OAuth callbacks for native MCP clients.""" + if parsed.fragment: + return False + # Query strings are not part of registered redirect_uris (RFC 6749 §3.1.2). + # Rejecting them prevents allowlist bypass via ``.../callback?injected=...``. + if parsed.query: + return False + if not parsed.netloc: + return False + if parsed.username is not None or parsed.password is not None: + return False + if "\\" in parsed.netloc: + return False + + normalized = _normalize_native_redirect_uri(parsed) + for entry in _parse_trusted_native_redirect_uris(): + if entry.endswith("*"): + if _native_wildcard_prefix_matches(normalized, entry[:-1]): + return True + elif normalized == entry: + return True + return False + + def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: """Accept ``redirect_uri`` when it is (a) same-origin with the - proxy's own request origin, (b) loopback, or (c) listed in the - ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist. + proxy's own request origin, (b) loopback, (c) listed in the + ``MCP_TRUSTED_REDIRECT_ORIGINS`` ops allowlist, or (d) a built-in / + env-configured native MCP client callback (e.g. ``cursor://``). Same-origin is VERIA-57's threat-model-safe equivalent of loopback: an attacker who can host content on the proxy's own HTTPS origin @@ -239,6 +321,8 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: except ValueError: raise HTTPException(status_code=400, detail="invalid_request") if parsed.scheme not in ("http", "https"): + if _matches_trusted_native_redirect_uri(parsed): + return raise HTTPException(status_code=400, detail="invalid_request") if parsed.fragment: raise HTTPException(status_code=400, detail="invalid_request") @@ -310,9 +394,12 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: "Inbound headers: X-Forwarded-Proto=%r X-Forwarded-Host=%r " "X-Forwarded-Port=%r Host=%r. " "Trusted-redirect-origins env=%r. " + "Trusted-native-redirect-uris env=%r. " "If this should be accepted, either align ingress X-Forwarded-* " "with the browser URL, set PROXY_BASE_URL to your public origin, " - "or add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS.", + "add the redirect_uri host to MCP_TRUSTED_REDIRECT_ORIGINS, or " + "for native MCP clients (cursor://, etc.) add the full redirect_uri " + "to MCP_TRUSTED_NATIVE_REDIRECT_URIS.", redirect_uri, proxy_base, os.environ.get("PROXY_BASE_URL"), @@ -321,5 +408,6 @@ def validate_trusted_redirect_uri(request: Request, redirect_uri: str) -> None: request.headers.get("X-Forwarded-Port"), request.headers.get("Host"), os.environ.get(_TRUSTED_REDIRECT_ORIGINS_ENV), + os.environ.get(_TRUSTED_NATIVE_REDIRECT_URIS_ENV), ) raise HTTPException(status_code=400, detail="invalid_request") diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py index 66b96785f69..9f2feddb0e3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_byok_oauth_endpoints.py @@ -1229,6 +1229,143 @@ def test_validate_trusted_redirect_uri_rejects_fragment_and_bad_scheme(): assert exc.value.status_code == 400, uri +def test_validate_trusted_redirect_uri_accepts_cursor_native_callback(): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "cursor://anysphere.cursor-mcp/oauth/callback") + + +def test_validate_trusted_redirect_uri_rejects_unlisted_native_callback( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setenv("MCP_TRUSTED_NATIVE_REDIRECT_URIS", "") + # Clear defaults by patching — env-only path for this test + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + req = _make_trusted_request("http://localhost:4000/") + with pytest.raises(HTTPException) as exc: + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback" + ) + assert exc.value.status_code == 400 + + +def test_validate_trusted_redirect_uri_accepts_env_native_redirect_uri( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "vscode://my-app/oauth/callback", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "vscode://my-app/oauth/callback") + + +def test_validate_trusted_redirect_uri_rejects_native_callback_with_fragment(): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + req = _make_trusted_request("http://localhost:4000/") + with pytest.raises(HTTPException) as exc: + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback#frag" + ) + assert exc.value.status_code == 400 + + +def test_validate_trusted_redirect_uri_rejects_native_callback_with_query(): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + req = _make_trusted_request("http://localhost:4000/") + with pytest.raises(HTTPException) as exc: + validate_trusted_redirect_uri( + req, + "cursor://anysphere.cursor-mcp/oauth/callback?injected=anything", + ) + assert exc.value.status_code == 400 + + +def test_validate_trusted_redirect_uri_native_path_case_insensitive(monkeypatch): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "myapp://host/MyPath", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "myapp://host/MyPath") + + +def test_validate_trusted_redirect_uri_native_wildcard_respects_path_boundary( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "cursor://anysphere.cursor-mcp/oauth/callback*", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback/extra" + ) + with pytest.raises(HTTPException): + validate_trusted_redirect_uri( + req, "cursor://anysphere.cursor-mcp/oauth/callback-2" + ) + + +def test_validate_trusted_redirect_uri_native_wildcard_directory_prefix( + monkeypatch, +): + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + validate_trusted_redirect_uri, + ) + + monkeypatch.setattr( + "litellm.proxy._experimental.mcp_server.oauth_utils._DEFAULT_NATIVE_REDIRECT_URIS", + [], + ) + monkeypatch.setenv( + "MCP_TRUSTED_NATIVE_REDIRECT_URIS", + "cursor://anysphere.cursor-mcp/oauth/*", + ) + req = _make_trusted_request("http://localhost:4000/") + validate_trusted_redirect_uri(req, "cursor://anysphere.cursor-mcp/oauth/callback") + + def test_validate_trusted_redirect_uri_rejects_scheme_mismatch_on_same_host(): """Regression: an attacker who can serve http on the proxy's own host (e.g. by MITMing an unencrypted LAN hop) must not be able to