fix(mcp): style provider-neutral connection consent

This commit is contained in:
Joshua Valluru 2026-09-21 14:16:24 -07:00
parent c3461d8e8b
commit 00c4585286
4 changed files with 204 additions and 42 deletions

View file

@ -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'",
}
),
)

View file

@ -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>
"""

View file

@ -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

View file

@ -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 &lt;script&gt;alert(1)&lt;/script&gt;" in page
assert "&lt;img src=x onerror=&quot;alert(1)&quot;&gt;" in page
assert "<li><code>files:read</code></li>" in page
assert "&lt;/code&gt;&lt;script&gt;alert(1)&lt;/script&gt;" in page
assert "http://localhost:33418/callback?x=&lt;svg&gt;" in page
assert 'value="flow&quot; onmouseover=&quot;alert(1)"' in page
assert 'action="https://gateway.example/authorize/connection/complete?x=&lt;y&gt;"' in page
assert 'nonce="nonce&quot; onload=&quot;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