mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(mcp): allow native MCP OAuth support for cursor (#28327)
* feat(mcp): allow native MCP OAuth redirect URIs (cursor://) Discoverable OAuth /authorize rejected cursor:// callbacks because validate_trusted_redirect_uri only accepted http/https. Add an allowlisted native path with a built-in Cursor default and optional MCP_TRUSTED_NATIVE_REDIRECT_URIS env for other clients. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): address Greptile native redirect URI review Lowercase paths in normalizer so env allowlist entries match case- insensitively. Tighten wildcard prefix matching to reject sibling paths (e.g. callback-2) unless the prefix ends with /. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): reject query params on native OAuth redirect URIs Greptile: normalization stripped query strings before allowlist compare, so cursor://.../callback?injected=... could pass validation. Reject any native redirect_uri with a query component (same as fragments). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(model_cost_map): add mistral/ministral-8b-2512 entry Mistral rotated the 'mistral/mistral-tiny' alias to return 'ministral-8b-2512' as the response model, which is not in the cost map. This caused test_completion_mistral_api and test_completion_mistral_api_modified_input to fail in completion_cost lookup. Add the entry mirroring the existing openrouter/mistralai/ministral-8b-2512 pricing. * fix(mcp): lowercase default native redirect URIs Make _parse_trusted_native_redirect_uris apply the same lowercasing to built-in defaults as it does to env-var entries. * fix(tests): backfill local model_cost into remote-fetched map litellm.model_cost is loaded at import time from the URL pinned to main, so pricing entries that exist only in this branch (e.g. mistral/ministral-8b-2512, freshly added because Mistral now returns this id from mistral-tiny) are absent at test time and completion_cost lookups raise. Backfill the in-tree backup so cassette-driven cost calculations resolve against the entries that ship with the branch under test. Fixes the local_testing_part1 failures on test_completion_mistral_api and test_completion_mistral_api_modified_input. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
parent
06bbe3aa04
commit
4da0592283
2 changed files with 228 additions and 3 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue