Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/plugin_routes.py: 36%
99 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Plugin proxy routes for litellm.
4Enables external services to register as plugins and be proxied through
5the litellm proxy server.
7Config (in litellm config.yaml general_settings):
8 plugins:
9 - name: my-plugin
10 url: "http://localhost:3210"
11 display_name: "My Plugin"
12 plugin_key: "sk-..." # optional: plugin's own auth key
14Plugin iframe auth:
15 The UI calls GET /api/plugins/auth-token to receive a short-lived identity
16 claim ({user_id, user_role, plugin, exp}) encrypted with a per-plugin key
17 derived as HMAC-SHA256(LITELLM_SALT_KEY, plugin_name). The claim carries no
18 litellm bearer token, so a compromised plugin learns only the caller's
19 identity, never their credential. LITELLM_SALT_KEY itself is never shared
20 with plugins — each plugin holds only its own derived key.
21"""
23import base64
24import hashlib
25import hmac as _hmac
26import json
27import os
28import time
29from collections.abc import Mapping
30from typing import Final
32from cryptography.fernet import Fernet, InvalidToken
33from fastapi import APIRouter, Depends, HTTPException, Request, Response
35from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
36from litellm.proxy._types import PluginConfig, SpecialHeaders, UserAPIKeyAuth
37from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
38from litellm.types.llms.custom_http import httpxSpecialProvider
40router: Final = APIRouter()
42# Hop-by-hop headers (RFC 7230) and the litellm session cookie — never forwarded
43# to a plugin backend. Credential headers are added on top per-request from the
44# canonical SpecialHeaders set so the plugin only ever authenticates via its own
45# injected plugin_key.
46_HOP_BY_HOP_STRIP: Final = frozenset(
47 {
48 "host",
49 "connection",
50 "transfer-encoding",
51 "te",
52 "trailers",
53 "upgrade",
54 "cookie",
55 }
56)
59def _configured_key_header_names() -> frozenset[str]:
60 """The lowercased general_settings.litellm_key_header_name, if configured.
62 Read live from the proxy module (not import-time) so a custom key header set
63 via config is honoured without a restart. Returns empty when unset.
64 """
65 try:
66 from litellm.proxy import proxy_server
67 except Exception:
68 return frozenset()
69 general_settings: Final = getattr(proxy_server, "general_settings", None)
70 if not isinstance(general_settings, Mapping):
71 return frozenset()
72 name: Final[object] = general_settings.get("litellm_key_header_name")
73 return frozenset({name.lower()}) if isinstance(name, str) and name else frozenset()
76def _request_strip_headers() -> frozenset[str]:
77 """Headers to drop before forwarding a request to a plugin backend.
79 Every header user_api_key_auth accepts as a litellm credential is stripped —
80 Authorization, x-api-key, API-Key, x-goog-api-key, Ocp-Apim-Subscription-Key,
81 x-litellm-api-key, and any configured custom key header — so a plugin can
82 never be handed the caller's live litellm key (confused-deputy escalation).
83 """
84 return _HOP_BY_HOP_STRIP | SpecialHeaders.litellm_credential_header_names() | _configured_key_header_names()
87# Headers to strip from plugin RESPONSES before returning to the browser.
88# httpx already decompresses and de-chunks the body, so forwarding the wire
89# encoding headers causes clients to attempt double-decompression (garbage) or
90# incorrect length checks. set-cookie is removed so plugins cannot overwrite
91# litellm session cookies.
92_RESPONSE_STRIP: Final = {
93 "content-encoding",
94 "transfer-encoding",
95 "content-length",
96 "set-cookie",
97}
100def _safe_response_headers(raw: "Mapping[str, str]") -> dict[str, str]:
101 """Strip wire-encoding/cookie headers and force proxied responses inert.
103 Plugin-controlled bytes are served from the litellm dashboard origin, so a
104 compromised plugin could return an HTML/JS document that executes with the
105 admin's session against same-origin management APIs. A sandbox CSP forces
106 the response into an opaque origin with scripts disabled, and nosniff stops
107 content-type confusion from re-enabling execution. Both are set last so a
108 plugin cannot override them with its own headers.
109 """
110 return {
111 **{k: v for k, v in raw.items() if k.lower() not in _RESPONSE_STRIP},
112 "content-security-policy": "sandbox",
113 "x-content-type-options": "nosniff",
114 }
117# In-memory plugin registry — populated from general_settings at startup
118_plugin_registry: Final[dict[str, PluginConfig]] = {}
121# ---------------------------------------------------------------------------
122# Key derivation — audience-scoped per plugin so compromising one plugin
123# cannot be used to forge claims for another. LITELLM_SALT_KEY is NEVER
124# shared with plugins; each plugin only receives a key derived from
125# HMAC(LITELLM_SALT_KEY, plugin_name) which reveals nothing about the master.
126# ---------------------------------------------------------------------------
127def _plugin_fernet(plugin_name: str) -> Fernet:
128 """Return a Fernet cipher whose key is scoped to a specific plugin.
130 Key material: HMAC-SHA256(LITELLM_SALT_KEY, plugin_name).
131 A plugin possessing its own key cannot derive the master salt or
132 forge claims intended for a different plugin.
133 """
134 salt: Final = os.getenv("LITELLM_SALT_KEY", "").encode()
135 derived: Final = _hmac.new(salt, plugin_name.encode(), hashlib.sha256).digest()
136 return Fernet(base64.urlsafe_b64encode(derived))
139_CLAIM_TTL_SECONDS: Final = 30 # identity claims expire after 30 s
142def issue_plugin_session_claim(plugin_name: str, user_id: str | None, user_role: str | None) -> str:
143 """Issue a short-lived, audience-scoped identity claim for the plugin.
145 The claim contains {user_id, user_role, plugin, exp}. Crucially it
146 contains NO litellm bearer token — the plugin can only derive the
147 caller's identity, not act as them against the proxy.
148 """
149 claim: Final = {
150 "plugin": plugin_name,
151 "user_id": user_id or "",
152 "user_role": user_role or "",
153 "exp": int(time.time()) + _CLAIM_TTL_SECONDS,
154 }
155 return _plugin_fernet(plugin_name).encrypt(json.dumps(claim).encode()).decode()
158def verify_plugin_session_claim(plugin_name: str, ciphertext: str) -> dict:
159 """Verify and decode a plugin session claim.
161 Raises ValueError if the HMAC is invalid, the audience is wrong, or
162 the claim is expired. Returns the decoded claim dict on success.
163 """
164 try:
165 raw: Final = _plugin_fernet(plugin_name).decrypt(ciphertext.encode(), ttl=_CLAIM_TTL_SECONDS)
166 claim: Final = json.loads(raw)
167 except (InvalidToken, Exception) as exc:
168 raise ValueError("Invalid, tampered, or expired plugin session claim") from exc
170 if claim.get("plugin") != plugin_name:
171 raise ValueError("Plugin claim audience mismatch")
172 if int(claim.get("exp", 0)) < int(time.time()):
173 raise ValueError("Plugin session claim expired")
174 return claim
177# ---------------------------------------------------------------------------
178# Config
179# ---------------------------------------------------------------------------
180def register_plugins_from_config(general_settings: dict[str, object]) -> None:
181 """Replace the plugin registry from general_settings.
183 Replaces (not merges) so plugins removed from config are immediately
184 unreachable without requiring a process restart.
185 """
186 raw: Final = general_settings.get("plugins")
187 entries: Final[list[object]] = raw if isinstance(raw, list) else []
188 new_registry: Final = {p.name: p for p in (PluginConfig.model_validate(entry) for entry in entries)}
189 _plugin_registry.clear()
190 _plugin_registry.update(new_registry)
193# ---------------------------------------------------------------------------
194# Routes
195# ---------------------------------------------------------------------------
196@router.get("/api/plugins", tags=["plugins"])
197async def list_plugins(
198 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
199) -> list[dict[str, str]]:
200 """Return registered plugins for authenticated UI callers.
202 plugin_key is never returned — the browser never needs it (the proxy injects
203 it server-side from the registry), and exposing it here would leak the
204 credential into React state and DevTools. Admin key management goes through
205 the redacted /config/field/info path instead.
206 """
207 return [
208 {
209 "name": plugin.name,
210 "display_name": plugin.display_name or plugin.name,
211 "url": plugin.url,
212 }
213 for plugin in _plugin_registry.values()
214 ]
217@router.get("/api/plugins/auth-token", tags=["plugins"])
218async def plugin_auth_token(
219 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
220 plugin_name: str = "litellm-platform-plugin",
221) -> dict:
222 """Issue a short-lived, audience-scoped plugin session claim.
224 The claim contains {user_id, user_role, plugin, exp}. It does NOT
225 contain the caller's litellm bearer token — a compromised plugin can
226 only learn the caller's identity, not impersonate them against the proxy.
228 Encrypted with a key derived from HMAC(LITELLM_SALT_KEY, plugin_name),
229 so each plugin holds only its own key and cannot forge claims for others.
231 Requires LITELLM_SALT_KEY to be set; returns 503 otherwise.
232 """
233 if not os.getenv("LITELLM_SALT_KEY"): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 raise HTTPException(
235 status_code=503,
236 detail="LITELLM_SALT_KEY is not configured; plugin iframe auth unavailable.",
237 )
238 if plugin_name not in _plugin_registry: 238 ↛ 240line 238 didn't jump to line 240 because the condition on line 238 was always true
239 raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' is not registered.")
240 user_id: Final = getattr(user_api_key_dict, "user_id", None)
241 user_role: Final = getattr(user_api_key_dict, "user_role", None)
242 return {"session_claim": issue_plugin_session_claim(plugin_name, user_id, user_role)}
245@router.api_route(
246 "/plugin-proxy/{plugin_name}/{path:path}",
247 methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
248 tags=["plugins"],
249 include_in_schema=False,
250)
251async def plugin_proxy(
252 plugin_name: str,
253 path: str,
254 request: Request,
255 user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
256) -> Response:
257 """Authenticated reverse-proxy to a registered plugin backend.
259 Restricted to proxy_admin callers — the shared plugin_key must not be
260 usable as a confused-deputy credential by regular users. Plugin UIs
261 talk to the plugin service directly via the iframe; this route is for
262 administrative and server-to-server access only.
264 The caller's litellm credential is stripped and replaced with the
265 plugin's own plugin_key so plugins never receive a live litellm API key.
266 """
267 if getattr(user_api_key_dict, "user_role", None) != "proxy_admin":
268 return Response(
269 content="Plugin proxy access requires proxy_admin role.",
270 status_code=403,
271 )
273 plugin: Final = _plugin_registry.get(plugin_name)
274 if not plugin:
275 return Response(
276 content=f"Plugin '{plugin_name}' not registered",
277 status_code=404,
278 )
280 target_url = f"{plugin.url.rstrip('/')}/{path}"
281 query: Final = request.url.query
282 if query:
283 target_url = f"{target_url}?{query}"
285 body: Final = await request.body()
287 # Strip caller credentials and hop-by-hop headers from forwarded request
288 strip: Final = _request_strip_headers()
289 forward_headers: Final = {k: v for k, v in request.headers.items() if k.lower() not in strip}
291 # Inject plugin's own credential as upstream auth (if configured)
292 plugin_key: Final = plugin.plugin_key
293 if plugin_key:
294 forward_headers["authorization"] = f"Bearer {plugin_key}"
296 # Forward caller identity so the plugin can enforce its own access control.
297 # The plugin MUST NOT trust these as credentials — they are informational.
298 # The plugin_key above is the only authentication mechanism.
299 user_id: Final = getattr(user_api_key_dict, "user_id", None)
300 user_role: Final = getattr(user_api_key_dict, "user_role", None)
301 if user_id:
302 forward_headers["x-litellm-user-id"] = str(user_id)
303 if user_role:
304 forward_headers["x-litellm-user-role"] = str(user_role)
306 handler: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.PassThroughEndpoint)
307 try:
308 req: Final = handler.client.build_request(
309 method=request.method,
310 url=target_url,
311 headers=forward_headers,
312 content=body,
313 )
314 # Do not follow redirects — a redirect to an internal URL would allow
315 # the plugin to SSRF the proxy into fetching arbitrary internal services.
316 resp: Final = await handler.client.send(req, follow_redirects=False)
317 except Exception:
318 return Response(
319 content=f"Cannot connect to plugin '{plugin_name}' at {plugin.url}",
320 status_code=502,
321 )
323 return Response(
324 content=resp.content,
325 status_code=resp.status_code,
326 headers=_safe_response_headers(resp.headers),
327 )