Skip to content

Constrain playground custom-model endpoint/api_key_env to server-declared pairs - #27

Merged
sklinglernv merged 1 commit into
mainfrom
security/playground-model-boundary
Jul 27, 2026
Merged

Constrain playground custom-model endpoint/api_key_env to server-declared pairs#27
sklinglernv merged 1 commit into
mainfrom
security/playground-model-boundary

Conversation

@sklinglernv

Copy link
Copy Markdown
Collaborator

Summary

Fixes a secret-exfiltration + SSRF vulnerability in the viewer playground.

Custom models registered via the playground API (POST /api/playground/models) are browser-controlled state, but their endpoint and api_key_env were forwarded unvalidated into litellm in run_inference:

kwargs["api_base"] = model_config["endpoint"]                       # attacker URL
api_key = resolve_api_key_from_config(request.model, model_config)  # os.getenv(any name)
kwargs["api_key"] = api_key
await litellm.acompletion(**kwargs)                                 # key POSTed to that URL

An attacker who can reach the viewer could register a model where:

  • endpoint → their own host and api_key_env → any process env var (e.g. a cloud/DB secret) → the resolved secret is sent to the attacker (secret exfiltration), or
  • endpointhttp://169.254.169.254/... or an internal service → SSRF into the metadata service / internal network.

Fix

Custom models may only reuse an (endpoint, api_key_env) pair the operator already declared server-side in models.yaml (or provider defaults) — never invent one.

  • unifiedllm/registry.pyresolve_api_key_from_config validates api_key_env against a shell-style env-var regex and accepts an optional allowed_env_vars allowlist (defense-in-depth for callers handling untrusted config).
  • viewer/trace_routes.py — trusted pairs are built from the server-side models config; enforced on write (HTTP 400) and stripped on read (get_playground_models, get_model_config). run_inference resolves keys only from the known api-key allowlist. load_custom_models also gains a type-confusion guard.

Extracted from 0001-playground-model-boundary of the security-hardening patch set (the other patches in that set cover unrelated issues and are not included here).

Testing

  • Full suite: 6437 passed, 4 skipped (uv run pytest).
  • All pre-commit hooks pass (ruff, ruff-format, pyright, hygiene checks). One pyright type-inference fix was applied on top of the patch (pairs set annotation).

🤖 Generated with Claude Code

…ared pairs

Custom models registered via the viewer playground API are browser-controlled
state, but their `endpoint` and `api_key_env` were forwarded unvalidated into
litellm. An attacker could register a model pointing `endpoint` at their own
host and `api_key_env` at any process env var, causing the resolved secret to
be POSTed to the attacker (secret exfiltration) or the server to make requests
to internal/metadata addresses (SSRF).

Harden the boundary:
- registry.resolve_api_key_from_config: validate api_key_env against a
  shell-style env-var regex and accept an optional allowed_env_vars allowlist.
- viewer.trace_routes: custom models may only reuse (endpoint, api_key_env)
  pairs declared in the server-side models config (or provider defaults).
  Enforced on write (400) and stripped on read; run_inference resolves keys
  only from the known api-key allowlist.

Extracted from 0001-playground-model-boundary of the security hardening set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sklinglernv
sklinglernv force-pushed the security/playground-model-boundary branch from f404877 to 3cc0919 Compare July 27, 2026 10:10
@alessiodevoto

Copy link
Copy Markdown
Collaborator

LGTM!

@sklinglernv
sklinglernv merged commit 75e7f57 into main Jul 27, 2026
@alessiodevoto
alessiodevoto deleted the security/playground-model-boundary branch August 3, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants