mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
fix(mcp): style provider-neutral connection consent
This commit is contained in:
parent
c3461d8e8b
commit
00c4585286
4 changed files with 204 additions and 42 deletions
|
|
@ -98,6 +98,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.common_utils.html_forms.native_client_consent import (
|
||||
render_mcp_connection_consent_page,
|
||||
render_native_client_consent_page,
|
||||
)
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
|
@ -1668,8 +1669,6 @@ async def authorize_connection(
|
|||
resource: str | None,
|
||||
scope: str | None,
|
||||
) -> Response:
|
||||
from html import escape
|
||||
|
||||
rejected: Final = _rejected_authorize_request(
|
||||
client_id, redirect_uri, state, code_challenge, code_challenge_method, response_type
|
||||
)
|
||||
|
|
@ -1700,23 +1699,23 @@ async def authorize_connection(
|
|||
exp=int(datetime.now(timezone.utc).timestamp()) + CONNECT_FLOW_TTL_SECONDS,
|
||||
)
|
||||
action: Final = f"{get_request_base_url(request)}/authorize/connection/complete"
|
||||
style_nonce: Final = secrets.token_urlsafe(18)
|
||||
response: Final = HTMLResponse(
|
||||
'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="referrer" content="no-referrer">'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize MCP connection</title>'
|
||||
"<h1>Authorize MCP connection</h1>"
|
||||
f"<p><strong>{escape(client.client_name or 'MCP client')}</strong> wants access to "
|
||||
f"<strong>{escape(server.name)}</strong>.</p><p>Return address: <code>{escape(redirect_uri)}</code></p>"
|
||||
f"<p>Requested permissions: {escape(scopes or 'provider defaults')}</p>"
|
||||
"<p>Only approve if you started this connection. You will continue to the provider to sign in.</p>"
|
||||
f'<form method="post" action="{escape(action)}"><input type="hidden" name="flow" value="{escape(handle)}">'
|
||||
'<button name="decision" value="deny">Deny</button> <button name="decision" value="approve">Continue</button>'
|
||||
"</form></html>",
|
||||
render_mcp_connection_consent_page(
|
||||
client_name=client.client_name or "MCP client",
|
||||
server_name=server.name,
|
||||
scopes=tuple(scopes.split()),
|
||||
redirect_uri=redirect_uri,
|
||||
flow_handle=handle,
|
||||
complete_url=action,
|
||||
style_nonce=style_nonce,
|
||||
),
|
||||
headers=MappingProxyType(
|
||||
{
|
||||
**TOKEN_NO_CACHE_HEADERS,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
||||
"Content-Security-Policy": f"default-src 'none'; style-src 'nonce-{style_nonce}'; frame-ancestors 'none'",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,37 @@ from typing import Final
|
|||
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
|
||||
_CONSENT_STYLES: Final = """
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
color: #1e293b;
|
||||
}
|
||||
.container {
|
||||
background-color: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
width: 450px;
|
||||
max-width: 100%;
|
||||
}
|
||||
h1 { margin: 0 0 16px; font-size: 24px; font-weight: 600; }
|
||||
p { margin: 0 0 12px; line-height: 1.5; }
|
||||
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }
|
||||
label { display: block; margin: 16px 0 6px; font-weight: 600; }
|
||||
select { width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; }
|
||||
.actions { display: flex; gap: 12px; margin-top: 24px; }
|
||||
button { flex: 1; padding: 10px; border-radius: 6px; font-size: 15px; cursor: pointer; border: 1px solid #cbd5e1; }
|
||||
.approve { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.deny { background: #fff; color: #1e293b; }
|
||||
"""
|
||||
|
||||
|
||||
def render_native_client_consent_page(
|
||||
*,
|
||||
|
|
@ -25,34 +56,7 @@ def render_native_client_consent_page(
|
|||
<meta name="referrer" content="no-referrer">
|
||||
<title>Authorize CLI access - LiteLLM</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
color: #1e293b;
|
||||
}}
|
||||
.container {{
|
||||
background-color: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
width: 450px;
|
||||
max-width: 100%;
|
||||
}}
|
||||
h1 {{ margin: 0 0 16px; font-size: 24px; font-weight: 600; }}
|
||||
p {{ margin: 0 0 12px; line-height: 1.5; }}
|
||||
code {{ background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }}
|
||||
label {{ display: block; margin: 16px 0 6px; font-weight: 600; }}
|
||||
select {{ width: 100%; padding: 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px; }}
|
||||
.actions {{ display: flex; gap: 12px; margin-top: 24px; }}
|
||||
button {{ flex: 1; padding: 10px; border-radius: 6px; font-size: 15px; cursor: pointer; border: 1px solid #cbd5e1; }}
|
||||
.approve {{ background: #2563eb; color: #fff; border-color: #2563eb; }}
|
||||
.deny {{ background: #fff; color: #1e293b; }}
|
||||
{_CONSENT_STYLES}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -89,3 +93,73 @@ def _team_field(teams: Sequence[tuple[str, str]]) -> str:
|
|||
return (
|
||||
f'<label for="team_id">Attribute requests to team</label><select id="team_id" name="team_id">{options}</select>'
|
||||
)
|
||||
|
||||
|
||||
def render_mcp_connection_consent_page(
|
||||
*,
|
||||
client_name: str,
|
||||
server_name: str,
|
||||
scopes: Sequence[str],
|
||||
redirect_uri: str,
|
||||
flow_handle: str,
|
||||
complete_url: str,
|
||||
style_nonce: str,
|
||||
) -> str:
|
||||
permissions: Final = (
|
||||
'<ul class="permissions">' + "".join(f"<li><code>{escape(scope)}</code></li>" for scope in scopes) + "</ul>"
|
||||
if scopes
|
||||
else '<p class="secondary">Default permissions configured by the server.</p>'
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Connect to {escape(server_name)} - LiteLLM</title>
|
||||
<style nonce="{escape(style_nonce)}">
|
||||
{_CONSENT_STYLES}
|
||||
body {{ box-sizing: border-box; padding: 24px; }}
|
||||
.container {{ box-sizing: border-box; width: 480px; padding: 32px; border: 1px solid #e2e8f0; border-radius: 12px; }}
|
||||
.brand {{ font-size: 18px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 28px; }}
|
||||
h1 {{ line-height: 1.3; overflow-wrap: anywhere; }}
|
||||
h2 {{ font-size: 13px; font-weight: 600; margin: 0 0 10px; }}
|
||||
.secondary {{ color: #475569; font-size: 14px; }}
|
||||
.permissions {{ list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }}
|
||||
code {{ font-size: 12px; line-height: 1.7; overflow-wrap: anywhere; word-break: break-word; }}
|
||||
section {{ padding: 20px 0; border-top: 1px solid #e2e8f0; }}
|
||||
.return-address {{ display: block; background: #f8fafc; padding: 10px 12px; }}
|
||||
.notice {{ padding: 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0; }}
|
||||
button {{ font-weight: 600; min-height: 44px; }}
|
||||
button:hover {{ filter: brightness(0.97); }}
|
||||
button:focus-visible {{ outline: 3px solid #93c5fd; outline-offset: 3px; }}
|
||||
.next-step {{ margin: 16px 0 0; text-align: center; font-size: 12px; color: #64748b; }}
|
||||
@media (max-width: 480px) {{ body {{ padding: 16px; }} .container {{ padding: 24px; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<div class="brand">LiteLLM</div>
|
||||
<h1>Connect to <bdi>{escape(server_name)}</bdi></h1>
|
||||
<p class="secondary"><strong><bdi>{escape(client_name)}</bdi></strong> is requesting access through LiteLLM.</p>
|
||||
<section aria-labelledby="permissions-heading">
|
||||
<h2 id="permissions-heading">Requested permissions</h2>
|
||||
{permissions}
|
||||
</section>
|
||||
<section aria-labelledby="return-heading">
|
||||
<h2 id="return-heading">Return to client</h2>
|
||||
<code class="return-address">{escape(redirect_uri)}</code>
|
||||
</section>
|
||||
<p class="secondary notice">Only continue if you started this connection and recognize the client and return address.</p>
|
||||
<form method="post" action="{escape(complete_url)}">
|
||||
<input type="hidden" name="flow" value="{escape(flow_handle)}">
|
||||
<div class="actions">
|
||||
<button type="submit" name="decision" value="deny" class="deny">Cancel</button>
|
||||
<button type="submit" name="decision" value="approve" class="approve">Continue</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="next-step">Next, authorize access on the server's sign-in page.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -12422,6 +12422,7 @@ def keyed_oauth_client(monkeypatch):
|
|||
|
||||
|
||||
def _start_keyed_oauth(harness):
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
client = harness.client
|
||||
|
|
@ -12461,6 +12462,13 @@ def _start_keyed_oauth(harness):
|
|||
assert "http://localhost:33418/callback" in consent.text
|
||||
assert "read:user" in consent.text
|
||||
assert "/sso/" not in consent.text
|
||||
style_nonce = re.search(r'<style nonce="([A-Za-z0-9_-]+)">', consent.text)
|
||||
assert style_nonce is not None, "The consent page needs styling permitted by its CSP"
|
||||
assert f"style-src 'nonce-{style_nonce.group(1)}'" in consent.headers["content-security-policy"]
|
||||
assert "default-src 'none'" in consent.headers["content-security-policy"]
|
||||
assert "frame-ancestors 'none'" in consent.headers["content-security-policy"]
|
||||
assert "unsafe-inline" not in consent.headers["content-security-policy"]
|
||||
assert "form-action" not in consent.headers["content-security-policy"]
|
||||
handle = next(
|
||||
cookie.name.removeprefix("mcp_connection_")
|
||||
for cookie in client.cookies.jar
|
||||
|
|
@ -12469,6 +12477,39 @@ def _start_keyed_oauth(harness):
|
|||
return client_id, verifier, handle
|
||||
|
||||
|
||||
@pytest.mark.parametrize("server_name", ["Calendar", "Documents <untrusted>"])
|
||||
def test_keyed_connection_consent_discloses_selected_server_without_provider_assumptions(
|
||||
keyed_oauth_client, server_name
|
||||
):
|
||||
from html import escape
|
||||
|
||||
harness = keyed_oauth_client
|
||||
harness.validate.return_value = harness.server.model_copy(update={"name": server_name})
|
||||
client_id, _, _ = _start_keyed_oauth(harness)
|
||||
page = harness.client.get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": client_id,
|
||||
"redirect_uri": "http://localhost:33418/callback",
|
||||
"response_type": "code",
|
||||
"state": "client-state",
|
||||
"code_challenge": "c" * 43,
|
||||
"code_challenge_method": "S256",
|
||||
"resource": harness.binding.resource,
|
||||
"scope": "read:user",
|
||||
},
|
||||
)
|
||||
assert page.status_code == 200
|
||||
assert escape(server_name) in page.text
|
||||
assert "GitHub" not in page.text
|
||||
assert "<untrusted>" not in page.text
|
||||
assert "Cancel</button>" in page.text
|
||||
assert "Continue</button>" in page.text
|
||||
assert 'type="password"' not in page.text
|
||||
harness.upstream.post.assert_not_awaited()
|
||||
harness.vault.assert_not_awaited()
|
||||
|
||||
|
||||
def _complete_keyed_oauth(harness):
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
|
||||
|
||||
from litellm.constants import CLI_JWT_EXPIRATION_HOURS
|
||||
from litellm.proxy.common_utils.html_forms.native_client_consent import render_native_client_consent_page
|
||||
from litellm.proxy.common_utils.html_forms.native_client_consent import (
|
||||
render_mcp_connection_consent_page,
|
||||
render_native_client_consent_page,
|
||||
)
|
||||
|
||||
|
||||
def _render(teams=(), **overrides) -> str:
|
||||
|
|
@ -65,3 +68,48 @@ def test_consent_page_promises_only_what_logout_can_deliver():
|
|||
assert f"expires within {CLI_JWT_EXPIRATION_HOURS} hours" in page
|
||||
assert "<code>lite logout</code> stops it from being renewed" in page
|
||||
assert "revoked" not in page
|
||||
|
||||
|
||||
def test_connection_consent_escapes_client_server_permissions_and_form_values():
|
||||
page = render_mcp_connection_consent_page(
|
||||
client_name='<img src=x onerror="alert(1)">',
|
||||
server_name="Documents <script>alert(1)</script>",
|
||||
scopes=("files:read", "</code><script>alert(1)</script>"),
|
||||
redirect_uri="http://localhost:33418/callback?x=<svg>",
|
||||
flow_handle='flow" onmouseover="alert(1)',
|
||||
complete_url="https://gateway.example/authorize/connection/complete?x=<y>",
|
||||
style_nonce='nonce" onload="alert(1)',
|
||||
)
|
||||
assert "<script>" not in page
|
||||
assert "<img " not in page
|
||||
assert "<svg>" not in page
|
||||
assert 'onmouseover="alert' not in page
|
||||
assert 'onload="alert' not in page
|
||||
assert "Documents <script>alert(1)</script>" in page
|
||||
assert "<img src=x onerror="alert(1)">" in page
|
||||
assert "<li><code>files:read</code></li>" in page
|
||||
assert "</code><script>alert(1)</script>" in page
|
||||
assert "http://localhost:33418/callback?x=<svg>" in page
|
||||
assert 'value="flow" onmouseover="alert(1)"' in page
|
||||
assert 'action="https://gateway.example/authorize/connection/complete?x=<y>"' in page
|
||||
assert 'nonce="nonce" onload="alert(1)"' in page
|
||||
|
||||
|
||||
def test_connection_consent_without_explicit_scopes_discloses_server_defaults():
|
||||
page = render_mcp_connection_consent_page(
|
||||
client_name="Desktop client",
|
||||
server_name="Calendar",
|
||||
scopes=(),
|
||||
redirect_uri="http://localhost:33418/callback",
|
||||
flow_handle="flow-123",
|
||||
complete_url="https://gateway.example/authorize/connection/complete",
|
||||
style_nonce="style-123",
|
||||
)
|
||||
assert "Default permissions configured by the server" in page
|
||||
assert "Desktop client" in page and "Calendar" in page
|
||||
assert 'name="flow" value="flow-123"' in page
|
||||
assert 'value="deny" class="deny">Cancel</button>' in page
|
||||
assert 'value="approve" class="approve">Continue</button>' in page
|
||||
assert 'type="password"' not in page
|
||||
assert 'name="user_id"' not in page
|
||||
assert 'name="team_id"' not in page
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue