diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index d1a576aeb33..2f65f99c292 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -120,6 +120,9 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/robots.txt", # Health (k8s probes) "/health", + # Plugin system + "/api/plugins", + "/plugin-proxy/", ) BACKEND_EXACT_PATHS: frozenset[str] = frozenset( diff --git a/docs/plugin_architecture.md b/docs/plugin_architecture.md new file mode 100644 index 00000000000..8801761531d --- /dev/null +++ b/docs/plugin_architecture.md @@ -0,0 +1,141 @@ +# LiteLLM Plugin Architecture + +Plugins let external services appear as selectable modes in the litellm UI sidebar alongside the AI Gateway. + +--- + +## Quick start + +### 1. Configure the plugin + +Add a `plugins` block to your litellm `config.yaml`: + +```yaml +general_settings: + master_key: sk-... + plugins: + - name: my-plugin # unique identifier (no spaces) + display_name: My Plugin # shown in the UI dropdown + url: "https://my-plugin.example.com" + plugin_key: "sk-..." # plugin's own auth credential +``` + +`plugin_key` is injected as `Authorization: Bearer ` on every +request proxied through `/plugin-proxy/my-plugin/*`. The caller's litellm +credential is stripped before forwarding so the plugin never receives a live +litellm API key. + +### 2. Implement two endpoints on your service + +| Endpoint | Method | Purpose | +|---|---|---| +| `GET /api/plugin-manifest` | public | Returns plugin metadata for the UI | +| `POST /api/plugin-auth` | public | Decrypts the identity claim for seamless sign-in | + +#### `GET /api/plugin-manifest` + +```json +{ + "name": "my-plugin", + "display_name": "My Plugin", + "version": "1.0.0", + "nav_items": [ + { "key": "home", "label": "Home", "icon": "HomeOutlined", "path": "/" }, + { "key": "reports", "label": "Reports", "icon": "BarChartOutlined", "path": "/reports" } + ], + "capabilities": ["reports", "data"] +} +``` + +#### `POST /api/plugin-auth` + +Receives `{ "session_claim": "" }`. + +The proxy never shares `LITELLM_SALT_KEY` with your plugin. Each plugin is +provisioned with its own dedicated key, derived as +`HMAC-SHA256(LITELLM_SALT_KEY, plugin_name)`. Compute it once on the proxy +host and hand the result to your plugin as a secret (e.g. `PLUGIN_AUTH_KEY`): + +```bash +python -c 'import base64,hmac,hashlib,os; \ +print(base64.urlsafe_b64encode(hmac.new(os.environ["LITELLM_SALT_KEY"].encode(), b"my-plugin", hashlib.sha256).digest()).decode())' +``` + +A compromised plugin holding only this scoped key cannot recover +`LITELLM_SALT_KEY` or decrypt any other litellm secret. + +Decrypt and validate the claim with that key: + +```python +import json, os, time +from cryptography.fernet import Fernet + +_CLAIM_TTL_SECONDS = 30 + +def plugin_auth(session_claim: str) -> dict: + cipher = Fernet(os.environ["PLUGIN_AUTH_KEY"].encode()) + claim = json.loads(cipher.decrypt(session_claim.encode(), ttl=_CLAIM_TTL_SECONDS)) + if claim.get("plugin") != "my-plugin": + raise ValueError("claim audience mismatch") + if int(claim.get("exp", 0)) < int(time.time()): + raise ValueError("claim expired") + return claim +``` + +The claim is `{ "plugin", "user_id", "user_role", "exp" }`; it carries no +litellm bearer token. Establish the plugin's own session from `user_id` / +`user_role` and authenticate API calls back to litellm through the +`/plugin-proxy/my-plugin/*` reverse proxy, which injects `plugin_key` for you. + +--- + +## How iframe auth works + +``` +litellm UI + ├─ GET /api/plugins/auth-token -> { session_claim } + └─ postMessage({ type:"litellm-auth", session_claim }, pluginOrigin) + │ + ▼ +Plugin iframe browser + └─ POST /api/plugin-auth { session_claim } + │ + ▼ +Plugin server + ├─ decrypt(session_claim, PLUGIN_AUTH_KEY) -> { user_id, user_role, exp } + └─ establish plugin session -> stored in sessionStorage +``` + +No litellm bearer token ever leaves the proxy; the claim only conveys the +caller's identity and expires after 30 seconds. A postMessage intercept +yields ciphertext that is useless without the plugin's scoped key. + +--- + +## Proxy routes + +- `GET /api/plugins` — list registered plugins (`name`, `display_name`, `url`). `plugin_key` is **never** returned; it stays server-side. Requires an authenticated caller. +- `GET /api/plugins/auth-token?plugin_name=` — short-lived encrypted identity claim for the named plugin. Requires `LITELLM_SALT_KEY` to be set (503 otherwise) and the plugin to be registered (404 otherwise). +- `ANY /plugin-proxy/{name}/{path}` — authenticated reverse proxy to the plugin backend. Restricted to `proxy_admin`. + +--- + +## Reverse proxy behaviour + +When an admin (or server-to-server caller) hits `/plugin-proxy//`, the proxy authenticates the caller locally, then rewrites the request before forwarding it to the plugin's `url`: + +- **Every litellm credential header is stripped** — `Authorization`, `x-api-key`, `API-Key`, `x-goog-api-key`, `Ocp-Apim-Subscription-Key`, `x-litellm-api-key`, any configured `litellm_key_header_name`, plus `Cookie`. The plugin can never be handed the caller's live litellm key. +- **`plugin_key` is injected** as `Authorization: Bearer ` — the only credential the plugin receives. +- **Caller identity is forwarded** as `x-litellm-user-id` and `x-litellm-user-role` so the plugin can run its own authorization. These are informational, not credentials. +- **Responses are sandboxed** — `Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff` are set so plugin-controlled bytes served from the litellm origin cannot execute against the dashboard. + +--- + +## Security checklist + +- [ ] `LITELLM_SALT_KEY` is set on the proxy and never shared with the plugin +- [ ] The plugin holds only its derived `HMAC(LITELLM_SALT_KEY, plugin_name)` key, provisioned as a dedicated secret +- [ ] `plugin_key` is a dedicated credential scoped to the plugin (not your litellm master key) +- [ ] Plugin's `POST /api/plugin-auth` enforces the claim's `plugin` audience and `exp` (30s TTL) +- [ ] Plugin treats `x-litellm-user-id` / `x-litellm-user-role` as identity hints, not as proof of authentication +- [ ] Plugin service URL uses HTTPS in production diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 8e2ec423cde..e856e5e3cdb 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2142,6 +2142,20 @@ class UserHeaderMapping(LiteLLMPydanticObjectBase): UserMCPManagementMode = Literal["restricted", "view_all"] +class PluginConfig(LiteLLMPydanticObjectBase): + """A single external service registered as an embeddable UI plugin.""" + + name: str = Field(description="unique plugin identifier (kebab-case)") + display_name: str | None = Field( + None, description="human-readable label shown in the UI view switcher" + ) + url: str = Field(description="base URL of the plugin service") + plugin_key: str | None = Field( + None, + description="plugin's own credential, injected as Bearer auth only on /plugin-proxy//* reverse-proxy calls", + ) + + class ConfigGeneralSettings(LiteLLMPydanticObjectBase): """ Documents all the fields supported by `general_settings` in config.yaml @@ -2150,6 +2164,9 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): completion_model: Optional[str] = Field( None, description="proxy level default model for all chat completion calls" ) + plugins: list[PluginConfig] | None = Field( + None, description="external services registered as embeddable UI plugins" + ) key_management_system: Optional[KeyManagementSystem] = Field( None, description="key manager to load keys from / decrypt keys with" ) @@ -3808,6 +3825,28 @@ class SpecialHeaders(enum.Enum): mcp_servers = "x-mcp-servers" mcp_access_groups = "x-mcp-access-groups" + @classmethod + def litellm_credential_header_names(cls) -> "frozenset[str]": + """Lowercased header names user_api_key_auth accepts as a litellm key. + + Every header here authenticates the caller, so any code that forwards a + request onward (e.g. the plugin reverse proxy) must strip all of them to + avoid leaking the caller's litellm credential downstream. The static + custom-key header (general_settings.litellm_key_header_name) is runtime + config and must be added on top of this set by the caller. + """ + return frozenset( + header.value.lower() + for header in ( + cls.openai_authorization, + cls.azure_authorization, + cls.anthropic_authorization, + cls.google_ai_studio_authorization, + cls.azure_apim_authorization, + cls.custom_litellm_api_key, + ) + ) + class LitellmDataForBackendLLMCall(TypedDict, total=False): headers: dict diff --git a/litellm/proxy/plugin_routes.py b/litellm/proxy/plugin_routes.py new file mode 100644 index 00000000000..6a94f78fbe7 --- /dev/null +++ b/litellm/proxy/plugin_routes.py @@ -0,0 +1,344 @@ +""" +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 cryptography.fernet import Fernet, InvalidToken +from fastapi import APIRouter, Depends, HTTPException, Request, Response + +from litellm.proxy._types import PluginConfig, SpecialHeaders, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.types.llms.custom_http import httpxSpecialProvider + +router = 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 = 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 = getattr(proxy_server, "general_settings", None) + if not isinstance(general_settings, dict): + return frozenset() + name: 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 = { + "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: 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 = os.getenv("LITELLM_SALT_KEY", "").encode() + derived = _hmac.new(salt, plugin_name.encode(), hashlib.sha256).digest() + return Fernet(base64.urlsafe_b64encode(derived)) + + +_CLAIM_TTL_SECONDS = 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 = { + "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 = _plugin_fernet(plugin_name).decrypt( + ciphertext.encode(), ttl=_CLAIM_TTL_SECONDS + ) + claim = 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 = general_settings.get("plugins") + entries: list[object] = raw if isinstance(raw, list) else [] + new_registry = { + 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 = getattr(user_api_key_dict, "user_id", None) + user_role = 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 = _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 = request.url.query + if query: + target_url = f"{target_url}?{query}" + + body = await request.body() + + # Strip caller credentials and hop-by-hop headers from forwarded request + strip = _request_strip_headers() + forward_headers = { + 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 = 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 = getattr(user_api_key_dict, "user_id", None) + user_role = 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 = get_async_httpx_client( + llm_provider=httpxSpecialProvider.PassThroughEndpoint + ) + try: + req = 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 = 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), + ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c138626a272..1bc23502165 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -419,6 +419,10 @@ from litellm.proxy.management_endpoints.workflow_management_endpoints import ( ) from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update from litellm.proxy.memory.memory_endpoints import router as memory_router +from litellm.proxy.plugin_routes import ( + router as plugin_router, + register_plugins_from_config, +) from litellm.proxy.middleware.in_flight_requests_middleware import ( InFlightRequestsMiddleware, ) @@ -4502,6 +4506,8 @@ class ProxyConfig: load_from_azure_key_vault(use_azure_key_vault=use_azure_key_vault) ### ALERTING ### self._load_alerting_settings(general_settings=general_settings) + ### PLUGINS ### + register_plugins_from_config(general_settings) ### CONNECT TO DATABASE ### database_url = general_settings.get("database_url", None) if database_url and database_url.startswith("os.environ/"): @@ -5663,6 +5669,10 @@ class ProxyConfig: llm_router=llm_router, ) + if _general_settings is not None and "plugins" in _general_settings: + general_settings["plugins"] = _general_settings["plugins"] + register_plugins_from_config(general_settings) + async def _reschedule_spend_log_cleanup_job(self): """ Reschedule the spend log cleanup job based on current general_settings. @@ -14931,6 +14941,41 @@ async def update_config( Keep it more precise, to prevent overwrite other values unintentially """ +_PLUGIN_KEY_REDACTED = "***" + + +def _preserve_redacted_plugin_keys(incoming: object, existing: object) -> object: + """Restore real plugin_key values the client never sees. + + /config/field/info redacts every plugin_key to ``"***"``, so an admin + editing a plugin posts that placeholder (or a blank, when the UI clears the + field) straight back. Treat a blank or redacted plugin_key as "keep the + stored credential" by sourcing it from the existing config; only a real, + non-redacted value replaces it, and a blank with no stored key drops the + field entirely instead of persisting the placeholder. + """ + if not isinstance(incoming, list): + return incoming + + stored_keys = { + p["name"]: p["plugin_key"] + for p in (existing if isinstance(existing, list) else []) + if isinstance(p, dict) and p.get("name") and p.get("plugin_key") + } + + def resolve(plugin: object) -> object: + if not isinstance(plugin, dict): + return plugin + key = plugin.get("plugin_key") + if key not in (None, "", _PLUGIN_KEY_REDACTED): + return plugin + name = plugin.get("name") + if name in stored_keys: + return {**plugin, "plugin_key": stored_keys[name]} + return {k: v for k, v in plugin.items() if k != "plugin_key"} + + return [resolve(p) for p in incoming] + @router.post( "/config/field/update", @@ -14997,7 +15042,13 @@ async def update_config_general_settings( ## update db - general_settings[data.field_name] = data.field_value + field_value = data.field_value + if data.field_name == "plugins": + field_value = _preserve_redacted_plugin_keys( + field_value, general_settings.get("plugins") + ) + + general_settings[data.field_name] = field_value response = await ConfigRepository(prisma_client).table.upsert( where={"param_name": "general_settings"}, @@ -15008,6 +15059,9 @@ async def update_config_general_settings( ) await invalidate_config_param("general_settings") + if data.field_name == "plugins": + register_plugins_from_config(general_settings) + return response @@ -15063,9 +15117,19 @@ async def get_config_general_settings( general_settings = dict(db_general_settings.param_value) if field_name in general_settings: - return ConfigFieldInfo( - field_name=field_name, field_value=general_settings[field_name] - ) + field_value = general_settings[field_name] + # Redact plugin_key from plugin configs so the shared credential + # is never returned even to admin-viewer callers. + if field_name == "plugins" and isinstance(field_value, list): + field_value = [ + ( + {k: ("***" if k == "plugin_key" else v) for k, v in p.items()} + if isinstance(p, dict) + else p + ) + for p in field_value + ] + return ConfigFieldInfo(field_name=field_name, field_value=field_value) else: raise HTTPException( status_code=400, @@ -16387,6 +16451,7 @@ app.include_router(model_access_group_management_router) app.include_router(tag_management_router) app.include_router(workflow_management_router) app.include_router(memory_router) +app.include_router(plugin_router) app.include_router(cost_tracking_settings_router) app.include_router(router_settings_router) app.include_router(fallback_management_router) diff --git a/tests/test_litellm/proxy/test_plugin_routes.py b/tests/test_litellm/proxy/test_plugin_routes.py new file mode 100644 index 00000000000..52999447179 --- /dev/null +++ b/tests/test_litellm/proxy/test_plugin_routes.py @@ -0,0 +1,232 @@ +"""Regression tests for UI-registered embed plugins. + +Covers three bugs: +1. `general_settings.plugins` was not a field on ConfigGeneralSettings, so the + admin UI's POST /config/field/update with field_name="plugins" was rejected + with "Invalid field=plugins passed in." +2. The in-memory plugin registry only refreshed at startup, so a plugin added + via the UI did not appear in /api/plugins until a restart. +3. Plugins persisted to DB general_settings were not loaded on startup (the + registry only initialised from the YAML config), so UI-added plugins vanished + after a restart. +""" + +import asyncio +from unittest.mock import MagicMock + +from litellm.proxy._types import ( + ConfigGeneralSettings, + LitellmUserRoles, + PluginConfig, + UserAPIKeyAuth, +) +from litellm.proxy.plugin_routes import list_plugins, register_plugins_from_config + + +def _admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-admin", user_role=LitellmUserRoles.PROXY_ADMIN) + + +def _non_admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER) + + +def test_plugins_is_a_valid_general_setting() -> None: + """The config-update endpoint gates on this exact membership check.""" + assert "plugins" in ConfigGeneralSettings.model_fields + + +def test_config_general_settings_parses_plugin_list() -> None: + """A list of plugin dicts (what the UI sends) coerces into PluginConfig.""" + settings = ConfigGeneralSettings.model_validate( + { + "plugins": [ + { + "name": "chat-ui", + "display_name": "Chat UI", + "url": "http://localhost:3300", + }, + { + "name": "agent-builder", + "url": "http://127.0.0.1:4010", + "plugin_key": "sk-secret", + }, + ] + } + ) + plugins = settings.plugins + assert plugins is not None + assert [p.name for p in plugins] == ["chat-ui", "agent-builder"] + assert isinstance(plugins[0], PluginConfig) + assert plugins[1].display_name is None + assert plugins[1].plugin_key == "sk-secret" + + +def test_registered_plugins_appear_in_list_without_restart() -> None: + """register_plugins_from_config makes UI-added plugins visible immediately, + and replaces (not merges) so removed plugins disappear.""" + register_plugins_from_config( + { + "plugins": [ + { + "name": "chat-ui", + "display_name": "Chat UI", + "url": "http://localhost:3300", + } + ] + } + ) + names = [p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin()))] + assert names == ["chat-ui"] + + register_plugins_from_config( + { + "plugins": [ + { + "name": "chat-ui", + "display_name": "Chat UI", + "url": "http://localhost:3300", + }, + { + "name": "agent-builder", + "display_name": "Agent Builder", + "url": "http://127.0.0.1:4010", + }, + ] + } + ) + names = sorted( + p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin())) + ) + assert names == ["agent-builder", "chat-ui"] + + # Removing a plugin from config drops it from the live list. + register_plugins_from_config({}) + assert asyncio.run(list_plugins(user_api_key_dict=_admin())) == [] + + +def test_plugin_key_is_never_returned_to_the_browser() -> None: + """plugin_key is a credential the UI never needs; /api/plugins must omit it + for every caller, admin included, so it never lands in browser state.""" + register_plugins_from_config( + { + "plugins": [ + { + "name": "p", + "display_name": "P", + "url": "http://localhost:9", + "plugin_key": "sk-secret", + } + ] + } + ) + + admin_entry = asyncio.run(list_plugins(user_api_key_dict=_admin()))[0] + user_entry = asyncio.run(list_plugins(user_api_key_dict=_non_admin()))[0] + + assert "plugin_key" not in admin_entry + assert "plugin_key" not in user_entry + assert admin_entry["url"] == "http://localhost:9" + + register_plugins_from_config({}) + + +def test_db_persisted_plugins_load_on_startup() -> None: + """Plugins saved to DB general_settings must register when the DB config is + merged at startup, not just when present in the YAML file.""" + from litellm.proxy.proxy_server import ProxyConfig + + register_plugins_from_config({}) # start empty (as if YAML had no plugins) + + ProxyConfig()._add_general_settings_from_db_config( + config_data={ + "general_settings": { + "plugins": [ + { + "name": "db-plugin", + "display_name": "DB Plugin", + "url": "http://localhost:5000", + } + ] + } + }, + general_settings={}, + proxy_logging_obj=MagicMock(), + ) + + names = [p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin()))] + assert names == ["db-plugin"] + + register_plugins_from_config({}) + + +def test_safe_response_headers_sandbox_and_strips_wire_headers() -> None: + """Proxied plugin responses must be inert and shed wire/cookie headers.""" + from litellm.proxy.plugin_routes import _safe_response_headers + + out = _safe_response_headers( + { + "content-type": "text/html", + "content-encoding": "gzip", + "content-length": "123", + "set-cookie": "session=abc", + "content-security-policy": "default-src *", + } + ) + + assert out["content-security-policy"] == "sandbox" + assert out["x-content-type-options"] == "nosniff" + assert out["content-type"] == "text/html" + for stripped in ("content-encoding", "content-length", "set-cookie"): + assert stripped not in out + + +def test_litellm_credential_header_names_covers_every_auth_header() -> None: + """The canonical strip set must list every header user_api_key_auth accepts + as a litellm key, so a new auth header can't silently start leaking.""" + from litellm.proxy._types import SpecialHeaders + + assert SpecialHeaders.litellm_credential_header_names() == { + "authorization", + "api-key", + "x-api-key", + "x-goog-api-key", + "ocp-apim-subscription-key", + "x-litellm-api-key", + } + + +def test_every_litellm_auth_header_is_stripped_before_forwarding() -> None: + """A plugin must never receive any header that authenticates against litellm, + only the hop-by-hop set and benign headers are forwarded.""" + from litellm.proxy.plugin_routes import _request_strip_headers + + strip = _request_strip_headers() + incoming = { + "Authorization": "Bearer sk-litellm", + "API-Key": "sk-litellm", + "X-Api-Key": "sk-litellm", + "X-Goog-Api-Key": "sk-litellm", + "Ocp-Apim-Subscription-Key": "sk-litellm", + "X-Litellm-Api-Key": "sk-litellm", + "Cookie": "litellm_session=abc", + "Accept": "application/json", + "X-Trace-Id": "t-1", + } + forwarded = {k: v for k, v in incoming.items() if k.lower() not in strip} + + assert forwarded == {"Accept": "application/json", "X-Trace-Id": "t-1"} + + +def test_configured_custom_key_header_is_stripped() -> None: + """A custom general_settings.litellm_key_header_name must also be stripped, + read live so config changes are honoured without a restart.""" + from litellm.proxy import proxy_server + from litellm.proxy.plugin_routes import _request_strip_headers + + original = getattr(proxy_server, "general_settings", None) + proxy_server.general_settings = {"litellm_key_header_name": "X-My-Tenant-Key"} + try: + assert "x-my-tenant-key" in _request_strip_headers() + finally: + proxy_server.general_settings = original diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 6017b9555e9..8b10539b188 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -8326,3 +8326,39 @@ def test_get_config_list_includes_cancel_on_disconnect(monkeypatch): assert fields["cancel_on_disconnect"]["field_type"] == "Boolean" finally: app.dependency_overrides.clear() + + +def test_preserve_redacted_plugin_keys_keeps_stored_credential(): + """A redacted or blank plugin_key on update must not overwrite the real key.""" + from litellm.proxy.proxy_server import _preserve_redacted_plugin_keys + + existing = [{"name": "p1", "url": "https://p1", "plugin_key": "sk-real-1"}] + + redacted = _preserve_redacted_plugin_keys( + [{"name": "p1", "url": "https://p1-new", "plugin_key": "***"}], existing + ) + assert redacted == [ + {"name": "p1", "url": "https://p1-new", "plugin_key": "sk-real-1"} + ] + + blanked = _preserve_redacted_plugin_keys( + [{"name": "p1", "url": "https://p1", "plugin_key": ""}], existing + ) + assert blanked[0]["plugin_key"] == "sk-real-1" + + +def test_preserve_redacted_plugin_keys_sets_new_and_drops_orphan_placeholder(): + """A real new key replaces; a placeholder with no stored key is dropped, never persisted.""" + from litellm.proxy.proxy_server import _preserve_redacted_plugin_keys + + existing = [{"name": "p1", "url": "https://p1", "plugin_key": "sk-real-1"}] + + rotated = _preserve_redacted_plugin_keys( + [{"name": "p1", "url": "https://p1", "plugin_key": "sk-new"}], existing + ) + assert rotated[0]["plugin_key"] == "sk-new" + + new_plugin = _preserve_redacted_plugin_keys( + [{"name": "p2", "url": "https://p2", "plugin_key": "***"}], existing + ) + assert "plugin_key" not in new_plugin[0] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agentControlPlaneView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agentControlPlaneView.test.tsx new file mode 100644 index 00000000000..26de1eed7b8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agentControlPlaneView.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { AgentControlPlaneView } from "./layout"; + +const { getMock } = vi.hoisted(() => ({ getMock: vi.fn(() => Promise.resolve({ session_claim: "claim" })) })); + +const pluginModeValue = { + mode: "litellm-platform-plugin" as string, + setMode: vi.fn(), + plugins: [{ name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" }], + activePlugin: { name: "litellm-platform-plugin", display_name: "Chat UI", url: "http://localhost:3300" } as { + name: string; + display_name: string; + url: string; + } | null, +}; + +vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: () => pluginModeValue })); +vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ accessToken: "sk-test-token" }) })); + +vi.mock("@/lib/http/client", () => ({ + createApiClient: () => ({ get: getMock }), +})); +vi.mock("@/components/networking", () => ({ getProxyBaseUrl: () => "" })); + +describe("AgentControlPlaneView iframe", () => { + it("embeds the plugin at its ROOT url, never a hardcoded subpath like /sessions", () => { + const { container } = render(); + const iframe = container.querySelector("iframe"); + + expect(iframe).not.toBeNull(); + const src = iframe!.getAttribute("src")!; + expect(src).toBe("http://localhost:3300/"); + expect(src).not.toContain("/sessions"); + // title comes from the plugin's display_name, not a hardcoded label + expect(iframe!.getAttribute("title")).toBe("Chat UI"); + }); + + it("does not double the slash when the plugin url has a trailing slash", () => { + pluginModeValue.activePlugin = { + name: "litellm-platform-plugin", + display_name: "Chat UI", + url: "http://localhost:3300/", + }; + const { container } = render(); + expect(container.querySelector("iframe")!.getAttribute("src")).toBe("http://localhost:3300/"); + pluginModeValue.activePlugin = { + name: "litellm-platform-plugin", + display_name: "Chat UI", + url: "http://localhost:3300", + }; + }); + + it("does not leak the raw token in the iframe src (token goes via encrypted postMessage)", () => { + const { container } = render(); + expect(container.querySelector("iframe")!.getAttribute("src")).not.toContain("token"); + }); + + it("does not delegate clipboard-read to the untrusted plugin iframe", () => { + const { container } = render(); + const allow = container.querySelector("iframe")!.getAttribute("allow") ?? ""; + + expect(allow).not.toContain("clipboard-read"); + expect(allow).toContain("clipboard-write"); + }); + + it("requests the auth-token claim scoped to the active plugin, not a hardcoded default", async () => { + getMock.mockClear(); + pluginModeValue.activePlugin = { name: "reports-plugin", display_name: "Reports", url: "http://localhost:3300" }; + render(); + + await waitFor(() => expect(getMock).toHaveBeenCalled()); + const [path, opts] = getMock.mock.calls[0]; + expect(path).toBe("/api/plugins/auth-token"); + expect(opts.query).toEqual({ plugin_name: "reports-plugin" }); + + pluginModeValue.activePlugin = { + name: "litellm-platform-plugin", + display_name: "Chat UI", + url: "http://localhost:3300", + }; + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index b32bed44a87..a5e83436888 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { Suspense, useState } from "react"; +import React, { Suspense, useState, useRef, useEffect } from "react"; import Navbar from "@/components/navbar"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { ThemeProvider } from "@/contexts/ThemeContext"; @@ -9,6 +9,88 @@ import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages"; +import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext"; +import { createApiClient } from "@/lib/http/client"; +import { getProxyBaseUrl } from "@/components/networking"; + +const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ?? "" }); + +// Wrapper so PluginModeProvider receives the live accessToken from auth context, +// which means plugin data refreshes on login/logout without stale cookie reads. +function PluginModeProviderWithAuth({ children }: { children: React.ReactNode }) { + const { accessToken } = useAuth(); + return {children}; +} + +export function AgentControlPlaneView() { + const { activePlugin } = usePluginMode(); + const activePluginName = activePlugin?.name; + const agentPlatformUrl = activePlugin?.url ?? ""; + const { accessToken } = useAuth(); + const iframeRef = useRef(null); + const [auth, setAuth] = useState<{ plugin: string; claim: string } | null>(null); + + // Fetch a short-lived identity claim scoped to the *active* plugin. The claim + // is encrypted under that plugin's own per-plugin key, so it must be requested + // per plugin and re-fetched when the user switches plugins. + useEffect(() => { + if (!accessToken || !activePluginName) return; + let cancelled = false; + pluginApiClient + .get("/api/plugins/auth-token", { accessToken, query: { plugin_name: activePluginName } }) + .then((data: { session_claim?: string }) => { + if (!cancelled && data?.session_claim) setAuth({ plugin: activePluginName, claim: data.session_claim }); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [accessToken, activePluginName]); + + // Deliver the claim to the iframe via postMessage, but only while it was issued + // for the plugin currently mounted — never replay one plugin's claim to another. + // targetOrigin is the configured plugin URL — no other origin receives it. + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe || !auth || auth.plugin !== activePluginName || !agentPlatformUrl) return; + const send = () => { + iframe.contentWindow?.postMessage({ type: "litellm-auth", session_claim: auth.claim }, agentPlatformUrl); + }; + // Cover both orderings: the iframe may have already fired `load` before the + // claim arrived (send now), or it may load/reload later (send on the event). + send(); + iframe.addEventListener("load", send); + return () => iframe.removeEventListener("load", send); + }, [auth, activePluginName, agentPlatformUrl]); + + if (!agentPlatformUrl) { + return ( +
+
+

Plugin

+

Configure the plugin URL in settings

+
+
+ ); + } + + // Embed the plugin at its root; the plugin renders its own full UI (incl. nav) inside. + return ( +