mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""
|
|
Plugin proxy routes for litellm.
|
|
|
|
Enables external services to register as plugins and be proxied through
|
|
the litellm proxy server.
|
|
|
|
Config (in litellm config.yaml general_settings):
|
|
plugins:
|
|
- name: my-plugin
|
|
url: "http://localhost:3210"
|
|
display_name: "My Plugin"
|
|
plugin_key: "sk-..." # optional: plugin's own auth key
|
|
|
|
Plugin iframe auth:
|
|
The UI calls GET /api/plugins/auth-token to receive a short-lived identity
|
|
claim ({user_id, user_role, plugin, exp}) encrypted with a per-plugin key
|
|
derived as HMAC-SHA256(LITELLM_SALT_KEY, plugin_name). The claim carries no
|
|
litellm bearer token, so a compromised plugin learns only the caller's
|
|
identity, never their credential. LITELLM_SALT_KEY itself is never shared
|
|
with plugins — each plugin holds only its own derived key.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac as _hmac
|
|
import json
|
|
import os
|
|
import time
|
|
from collections.abc import Mapping
|
|
from typing import Final
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
|
|
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
|
from litellm.proxy._types import PluginConfig, SpecialHeaders, UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.types.llms.custom_http import httpxSpecialProvider
|
|
|
|
router: Final = APIRouter()
|
|
|
|
# Hop-by-hop headers (RFC 7230) and the litellm session cookie — never forwarded
|
|
# to a plugin backend. Credential headers are added on top per-request from the
|
|
# canonical SpecialHeaders set so the plugin only ever authenticates via its own
|
|
# injected plugin_key.
|
|
_HOP_BY_HOP_STRIP: Final = frozenset(
|
|
{
|
|
"host",
|
|
"connection",
|
|
"transfer-encoding",
|
|
"te",
|
|
"trailers",
|
|
"upgrade",
|
|
"cookie",
|
|
}
|
|
)
|
|
|
|
|
|
def _configured_key_header_names() -> frozenset[str]:
|
|
"""The lowercased general_settings.litellm_key_header_name, if configured.
|
|
|
|
Read live from the proxy module (not import-time) so a custom key header set
|
|
via config is honoured without a restart. Returns empty when unset.
|
|
"""
|
|
try:
|
|
from litellm.proxy import proxy_server
|
|
except Exception:
|
|
return frozenset()
|
|
general_settings: Final = getattr(proxy_server, "general_settings", None)
|
|
if not isinstance(general_settings, dict):
|
|
return frozenset()
|
|
name: Final[object] = general_settings.get("litellm_key_header_name")
|
|
return frozenset({name.lower()}) if isinstance(name, str) and name else frozenset()
|
|
|
|
|
|
def _request_strip_headers() -> frozenset[str]:
|
|
"""Headers to drop before forwarding a request to a plugin backend.
|
|
|
|
Every header user_api_key_auth accepts as a litellm credential is stripped —
|
|
Authorization, x-api-key, API-Key, x-goog-api-key, Ocp-Apim-Subscription-Key,
|
|
x-litellm-api-key, and any configured custom key header — so a plugin can
|
|
never be handed the caller's live litellm key (confused-deputy escalation).
|
|
"""
|
|
return _HOP_BY_HOP_STRIP | SpecialHeaders.litellm_credential_header_names() | _configured_key_header_names()
|
|
|
|
|
|
# Headers to strip from plugin RESPONSES before returning to the browser.
|
|
# httpx already decompresses and de-chunks the body, so forwarding the wire
|
|
# encoding headers causes clients to attempt double-decompression (garbage) or
|
|
# incorrect length checks. set-cookie is removed so plugins cannot overwrite
|
|
# litellm session cookies.
|
|
_RESPONSE_STRIP: Final = {
|
|
"content-encoding",
|
|
"transfer-encoding",
|
|
"content-length",
|
|
"set-cookie",
|
|
}
|
|
|
|
|
|
def _safe_response_headers(raw: "Mapping[str, str]") -> dict[str, str]:
|
|
"""Strip wire-encoding/cookie headers and force proxied responses inert.
|
|
|
|
Plugin-controlled bytes are served from the litellm dashboard origin, so a
|
|
compromised plugin could return an HTML/JS document that executes with the
|
|
admin's session against same-origin management APIs. A sandbox CSP forces
|
|
the response into an opaque origin with scripts disabled, and nosniff stops
|
|
content-type confusion from re-enabling execution. Both are set last so a
|
|
plugin cannot override them with its own headers.
|
|
"""
|
|
return {
|
|
**{k: v for k, v in raw.items() if k.lower() not in _RESPONSE_STRIP},
|
|
"content-security-policy": "sandbox",
|
|
"x-content-type-options": "nosniff",
|
|
}
|
|
|
|
|
|
# In-memory plugin registry — populated from general_settings at startup
|
|
_plugin_registry: Final[dict[str, PluginConfig]] = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Key derivation — audience-scoped per plugin so compromising one plugin
|
|
# cannot be used to forge claims for another. LITELLM_SALT_KEY is NEVER
|
|
# shared with plugins; each plugin only receives a key derived from
|
|
# HMAC(LITELLM_SALT_KEY, plugin_name) which reveals nothing about the master.
|
|
# ---------------------------------------------------------------------------
|
|
def _plugin_fernet(plugin_name: str) -> Fernet:
|
|
"""Return a Fernet cipher whose key is scoped to a specific plugin.
|
|
|
|
Key material: HMAC-SHA256(LITELLM_SALT_KEY, plugin_name).
|
|
A plugin possessing its own key cannot derive the master salt or
|
|
forge claims intended for a different plugin.
|
|
"""
|
|
salt: Final = os.getenv("LITELLM_SALT_KEY", "").encode()
|
|
derived: Final = _hmac.new(salt, plugin_name.encode(), hashlib.sha256).digest()
|
|
return Fernet(base64.urlsafe_b64encode(derived))
|
|
|
|
|
|
_CLAIM_TTL_SECONDS: Final = 30 # identity claims expire after 30 s
|
|
|
|
|
|
def issue_plugin_session_claim(plugin_name: str, user_id: str | None, user_role: str | None) -> str:
|
|
"""Issue a short-lived, audience-scoped identity claim for the plugin.
|
|
|
|
The claim contains {user_id, user_role, plugin, exp}. Crucially it
|
|
contains NO litellm bearer token — the plugin can only derive the
|
|
caller's identity, not act as them against the proxy.
|
|
"""
|
|
claim: Final = {
|
|
"plugin": plugin_name,
|
|
"user_id": user_id or "",
|
|
"user_role": user_role or "",
|
|
"exp": int(time.time()) + _CLAIM_TTL_SECONDS,
|
|
}
|
|
return _plugin_fernet(plugin_name).encrypt(json.dumps(claim).encode()).decode()
|
|
|
|
|
|
def verify_plugin_session_claim(plugin_name: str, ciphertext: str) -> dict:
|
|
"""Verify and decode a plugin session claim.
|
|
|
|
Raises ValueError if the HMAC is invalid, the audience is wrong, or
|
|
the claim is expired. Returns the decoded claim dict on success.
|
|
"""
|
|
try:
|
|
raw: Final = _plugin_fernet(plugin_name).decrypt(ciphertext.encode(), ttl=_CLAIM_TTL_SECONDS)
|
|
claim: Final = json.loads(raw)
|
|
except (InvalidToken, Exception) as exc:
|
|
raise ValueError("Invalid, tampered, or expired plugin session claim") from exc
|
|
|
|
if claim.get("plugin") != plugin_name:
|
|
raise ValueError("Plugin claim audience mismatch")
|
|
if int(claim.get("exp", 0)) < int(time.time()):
|
|
raise ValueError("Plugin session claim expired")
|
|
return claim
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
def register_plugins_from_config(general_settings: dict[str, object]) -> None:
|
|
"""Replace the plugin registry from general_settings.
|
|
|
|
Replaces (not merges) so plugins removed from config are immediately
|
|
unreachable without requiring a process restart.
|
|
"""
|
|
raw: Final = general_settings.get("plugins")
|
|
entries: Final[list[object]] = raw if isinstance(raw, list) else []
|
|
new_registry: Final = {p.name: p for p in (PluginConfig.model_validate(entry) for entry in entries)}
|
|
_plugin_registry.clear()
|
|
_plugin_registry.update(new_registry)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
@router.get("/api/plugins", tags=["plugins"])
|
|
async def list_plugins(
|
|
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
|
) -> list[dict[str, str]]:
|
|
"""Return registered plugins for authenticated UI callers.
|
|
|
|
plugin_key is never returned — the browser never needs it (the proxy injects
|
|
it server-side from the registry), and exposing it here would leak the
|
|
credential into React state and DevTools. Admin key management goes through
|
|
the redacted /config/field/info path instead.
|
|
"""
|
|
return [
|
|
{
|
|
"name": plugin.name,
|
|
"display_name": plugin.display_name or plugin.name,
|
|
"url": plugin.url,
|
|
}
|
|
for plugin in _plugin_registry.values()
|
|
]
|
|
|
|
|
|
@router.get("/api/plugins/auth-token", tags=["plugins"])
|
|
async def plugin_auth_token(
|
|
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
|
plugin_name: str = "litellm-platform-plugin",
|
|
) -> dict:
|
|
"""Issue a short-lived, audience-scoped plugin session claim.
|
|
|
|
The claim contains {user_id, user_role, plugin, exp}. It does NOT
|
|
contain the caller's litellm bearer token — a compromised plugin can
|
|
only learn the caller's identity, not impersonate them against the proxy.
|
|
|
|
Encrypted with a key derived from HMAC(LITELLM_SALT_KEY, plugin_name),
|
|
so each plugin holds only its own key and cannot forge claims for others.
|
|
|
|
Requires LITELLM_SALT_KEY to be set; returns 503 otherwise.
|
|
"""
|
|
if not os.getenv("LITELLM_SALT_KEY"):
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="LITELLM_SALT_KEY is not configured; plugin iframe auth unavailable.",
|
|
)
|
|
if plugin_name not in _plugin_registry:
|
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_name}' is not registered.")
|
|
user_id: Final = getattr(user_api_key_dict, "user_id", None)
|
|
user_role: Final = getattr(user_api_key_dict, "user_role", None)
|
|
return {"session_claim": issue_plugin_session_claim(plugin_name, user_id, user_role)}
|
|
|
|
|
|
@router.api_route(
|
|
"/plugin-proxy/{plugin_name}/{path:path}",
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
|
|
tags=["plugins"],
|
|
include_in_schema=False,
|
|
)
|
|
async def plugin_proxy(
|
|
plugin_name: str,
|
|
path: str,
|
|
request: Request,
|
|
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
|
) -> Response:
|
|
"""Authenticated reverse-proxy to a registered plugin backend.
|
|
|
|
Restricted to proxy_admin callers — the shared plugin_key must not be
|
|
usable as a confused-deputy credential by regular users. Plugin UIs
|
|
talk to the plugin service directly via the iframe; this route is for
|
|
administrative and server-to-server access only.
|
|
|
|
The caller's litellm credential is stripped and replaced with the
|
|
plugin's own plugin_key so plugins never receive a live litellm API key.
|
|
"""
|
|
if getattr(user_api_key_dict, "user_role", None) != "proxy_admin":
|
|
return Response(
|
|
content="Plugin proxy access requires proxy_admin role.",
|
|
status_code=403,
|
|
)
|
|
|
|
plugin: Final = _plugin_registry.get(plugin_name)
|
|
if not plugin:
|
|
return Response(
|
|
content=f"Plugin '{plugin_name}' not registered",
|
|
status_code=404,
|
|
)
|
|
|
|
target_url = f"{plugin.url.rstrip('/')}/{path}"
|
|
query: Final = request.url.query
|
|
if query:
|
|
target_url = f"{target_url}?{query}"
|
|
|
|
body: Final = await request.body()
|
|
|
|
# Strip caller credentials and hop-by-hop headers from forwarded request
|
|
strip: Final = _request_strip_headers()
|
|
forward_headers: Final = {k: v for k, v in request.headers.items() if k.lower() not in strip}
|
|
|
|
# Inject plugin's own credential as upstream auth (if configured)
|
|
plugin_key: Final = plugin.plugin_key
|
|
if plugin_key:
|
|
forward_headers["authorization"] = f"Bearer {plugin_key}"
|
|
|
|
# Forward caller identity so the plugin can enforce its own access control.
|
|
# The plugin MUST NOT trust these as credentials — they are informational.
|
|
# The plugin_key above is the only authentication mechanism.
|
|
user_id: Final = getattr(user_api_key_dict, "user_id", None)
|
|
user_role: Final = getattr(user_api_key_dict, "user_role", None)
|
|
if user_id:
|
|
forward_headers["x-litellm-user-id"] = str(user_id)
|
|
if user_role:
|
|
forward_headers["x-litellm-user-role"] = str(user_role)
|
|
|
|
handler: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.PassThroughEndpoint)
|
|
try:
|
|
req: Final = handler.client.build_request(
|
|
method=request.method,
|
|
url=target_url,
|
|
headers=forward_headers,
|
|
content=body,
|
|
)
|
|
# Do not follow redirects — a redirect to an internal URL would allow
|
|
# the plugin to SSRF the proxy into fetching arbitrary internal services.
|
|
resp: Final = await handler.client.send(req, follow_redirects=False)
|
|
except Exception:
|
|
return Response(
|
|
content=f"Cannot connect to plugin '{plugin_name}' at {plugin.url}",
|
|
status_code=502,
|
|
)
|
|
|
|
return Response(
|
|
content=resp.content,
|
|
status_code=resp.status_code,
|
|
headers=_safe_response_headers(resp.headers),
|
|
)
|