-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The endpoint that any LiteLLM key can reach\n\nLiteLLM is BerriAI's AI Gateway: a proxy server that sits between an organisation's applications and every upstream LLM provider (OpenAI, Anthropic, AWS Bedrock, Vertex, Cohere, dozens more), holds the provider API keys in its environment, and presents a unified OpenAI-shaped interface. The proxy's whole reason for existing is to centralise the credentials. Compromise the proxy process and you have the keys.\n\nThe vulnerable endpoint is `POST /prompts/test`, declared in `litellm/proxy/prompts/prompt_endpoints.py` at line 1186. Its guard is one line:\n\n```python\n@router.post(\n \"/prompts/test\",\n tags=[\"Prompt Management\"],\n dependencies=[Depends(user_api_key_auth)],\n)\nasync def test_prompt(\n request: TestPromptRequest,\n ...\n):\n```\n\n`user_api_key_auth` accepts any LiteLLM proxy key. Most deployments mint per-team or per-developer keys with scoped model and budget limits; none of those scopes restrict access to the prompt-management surface. A key minted to call `gpt-3.5-turbo` from a sales-ops automation can reach `/prompts/test`. So can anyone who phished one such key.\n\nThe handler accepts a JSON body containing a `dotprompt_content` string, a YAML-frontmatter prompt format Google ships with its GenAI Kit. The handler parses the frontmatter, takes the template body, and renders it:\n\n```python\nprompt_manager = PromptManager()\nfrontmatter, template_content = prompt_manager._parse_frontmatter(\n content=request.dotprompt_content\n)\n# ...\nvariables = request.prompt_variables or {}\nrendered_content = prompt_manager.jinja_env.from_string(\n template_content\n).render(**variables)\n```\n\n`prompt_manager.jinja_env` is the Jinja2 environment configured in the `PromptManager.__init__` at `litellm/integrations/dotprompt/prompt_manager.py` line 62. Before the patch:\n\n```python\nself.jinja_env = Environment(\n loader=DictLoader({}),\n autoescape=select_autoescape([\"html\", \"xml\"]),\n variable_start_string=\"{{\",\n variable_end_string=\"}}\",\n block_start_string=\"{%\",\n block_end_string=\"%}\",\n comment_start_string=\"{#\",\n comment_end_string=\"#}\",\n)\n```\n\nThat is `jinja2.Environment`, the unsandboxed default. It evaluates attribute access on whatever objects the template references. The proof-of-concept by GitHub user Astianjy, published on May 12, sends one curl:\n\n```bash\ncurl -X POST http://target:4000/prompts/test \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"dotprompt_content\": \"---\\nmodel: gpt-3.5-turbo\\ntemperature: 0\\n---\\n\\nUser: {{ cycler.__init__.__globals__.os.popen(\\\"id\\\").read() }}\",\n \"prompt_variables\": {}\n }'\n```\n\n`cycler` is a Jinja2 builtin. Its `__init__` is a function, every Python function has a `__globals__` attribute pointing at the module it was defined in, and the Jinja2 runtime module imports `os`. The chain reaches `os.popen`, which executes a shell command on the proxy host. The proxy host's environment contains, by design, the credentials for every upstream LLM provider the organisation pays.\n\nThis is the textbook server-side template injection primitive. It has had the same shape since 2016. It is in every Jinja2-security cheatsheet. It is the first thing a sandboxed environment blocks.\n\n## The sandbox was already in the codebase. In a different file.\n\nOn April 10, 2024, Ishaan Jaff committed `f9cabf09ff49544d3e677f34e6da26de06af1596` to LiteLLM. The commit message was `fix jinja2 use ImmutableSandboxedEnvironment`. Ishaan Jaff is BerriAI's co-founder. The file was `litellm/llms/prompt_templates/factory.py`. The diff replaced:\n\n```diff\n-from jinja2 import Template, exceptions, Environment, meta, BaseLoader\n+from jinja2 import Template, exceptions, meta, BaseLoader\n+from jinja2.sandbox import ImmutableSandboxedEnvironment\n```\n\nand inside `hf_chat_template`:\n\n```diff\n- env = Environment(loader=BaseLoader(), autoescape=False)\n+ env = ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)\n```\n\nThat commit landed two years and one day before CVE-2026-42203 was patched in a different file in the same repository. The codebase knew about Jinja2 SSTI in 2024. The codebase imported `ImmutableSandboxedEnvironment` in 2024. The codebase did not enforce any rule that subsequent Jinja2 environments would inherit the sandbox; each new prompt source got its own `__init__` and its own choice of which `jinja2` symbol to import.\n\nThe dotprompt module was added on July 31, 2025, in commit `fe24c270de` (*Prompt Management - add local dotprompt file support*). The author imported `Environment` from `jinja2`. There was no linter rule, no architectural review, no shared base class enforcing the sandbox. The file two directories away already had the answer.\n\nFifteen months later it became CVE-2026-42203.\n\n## One of four\n\nThe April 9 fix patched `litellm/integrations/dotprompt/prompt_manager.py`. Nothing else.\n\nOn May 2, 2026, GitHub user `stuxf` committed `de28a4f352bd5f1267973615e4ddbac6742630c2`. The commit title says what the April 9 commit's title did not: *fix(security): sandbox jinja2 in gitlab/arize/bitbucket prompt managers*. The commit body is the testimony:\n\n> DotpromptManager was hardened to render through ImmutableSandboxedEnvironment. The three sibling managers (gitlab, arize, bitbucket) were missed and still instantiate plain jinja2.Environment(), leaving the same attribute-traversal SSTI primitive open: a template fetched from a GitLab/BitBucket repo or Arize Phoenix workspace can reach `__class__.__init__.__globals__` and execute arbitrary Python on the proxy host.\n\nThe diff is the same diff jayden wrote three weeks earlier, applied to three other files:\n\n```diff\n--- a/litellm/integrations/gitlab/gitlab_prompt_manager.py\n-from jinja2 import DictLoader, Environment, select_autoescape\n+from jinja2 import DictLoader, select_autoescape\n+from jinja2.sandbox import ImmutableSandboxedEnvironment\n\n- self.jinja_env = Environment(\n+ self.jinja_env = ImmutableSandboxedEnvironment(\n```\n\nIdentical hunks land in `litellm/integrations/bitbucket/bitbucket_prompt_manager.py` and `litellm/integrations/arize/arize_phoenix_prompt_manager.py`. The commit adds 125 lines of regression tests (*tests/test_litellm/integrations/test_prompt_manager_ssti.py*) verifying each manager's `jinja_env` is a sandbox, that classic SSTI payloads raise `SecurityError`, and that ordinary variable rendering still works. Eighteen test cases. Three managers. Six cases each.\n\nThe threat surface on the three siblings is narrower than DotpromptManager's. The dotprompt one accepts a template body directly from any proxy key holder via HTTP. The gitlab/bitbucket/arize ones fetch templates from configured upstream repositories or workspaces; the attacker has to be able to commit to the repo or write to the workspace. But the primitive is identical: a fetched template containing `{{ cycler.__init__.__globals__.os.popen(...).read() }}` reaches Python execution on the proxy host. An organisation that wires LiteLLM up to read prompts from a shared GitLab project so multiple teams can manage them is trusting every committer to that project with the same authority as the proxy host's process. Until the May 2 commit, none of that was documented and none of it was enforced. None of it has a CVE.\n\nThis is the parallel-implementation-gap shape. LiteLLM has at least four implementations of *load a prompt template, render it with Jinja2*: `litellm/llms/prompt_templates/factory.py` (the canonical, sandboxed in April 2024), `litellm/integrations/dotprompt/prompt_manager.py`, `litellm/integrations/gitlab/gitlab_prompt_manager.py`, `litellm/integrations/bitbucket/bitbucket_prompt_manager.py`, and `litellm/integrations/arize/arize_phoenix_prompt_manager.py`. Each was authored separately, by a different developer, against a different prompt source. The first imported `ImmutableSandboxedEnvironment`. The four after it each imported `Environment`. The CVE names the divergent module that has a callable HTTP surface. The other three were waiting for the same researcher to look one directory over.\n\nIt is also a design-debt driver. LiteLLM's prompt-management architecture is *one manager class per prompt source, each owns its own `jinja2` import*. The repository's catalogue of integrations grows with the LLM-tooling ecosystem; every new prompt source (Langfuse next, Helicone next, whoever ships a prompt store after that) is a new `__init__` with a new `self.jinja_env = ...` call site and a new chance for the author to type `Environment` instead of `ImmutableSandboxedEnvironment`. The April 2024 patch fixed an instance. The architecture that produces a new instance each time a new source ships is what brought the same bug back in 2026, and is what will bring it back again the next time the catalogue grows.\n\n## The commit title that does not say SSTI\n\nThe April 9 commit was titled *fix(proxy): improve input validation on management endpoints*. The same commit also modified `key_management_endpoints.py` and `proxy_setting_endpoints.py`, both of which received unrelated authorisation-check hardening in the same diff. Bundling the SSTI patch with two cosmetic authorisation changes gave the commit a generic title that covers the smaller changes truthfully while obscuring the larger one. A defender reading the LiteLLM commit log on April 10 sees *improve input validation on management endpoints* and reasons, correctly, that no upgrade-now signal is fired. The CVE description, when it landed weeks later, named `/prompts/test` and *prompt templates rendered without sandboxing*. The commit shipped in v1.83.7-stable on April 11; CVE-2026-42203 was not assigned to v1.80.5..1.83.7 until later. The window between the silent patch and the public CVE is the window every adversary running LiteLLM-aware tooling against exposed proxies was scanning unannounced.\n\nThe May 2 follow-up commit's title says *fix(security): sandbox jinja2 in gitlab/arize/bitbucket prompt managers* and its body explains attribute traversal. Two commits, three weeks apart, fix the same primitive in four files. One was titled like a routine input-validation pass. One was titled like the security advisory it deserved. The second author wrote the second title because the first author's title had already established the cover.\n\n## The charge\n\nBerriAI ships LiteLLM as the recommended way for an organisation to put one HTTP endpoint in front of every paid LLM provider, hold the provider keys in one process, and apply one key-management layer to internal callers. The architecture means the proxy process is, by the moment it starts, the most secret-laden box in the deployment. BerriAI's own co-founder committed the fix for Jinja2 SSTI in `factory.py` in April 2024. BerriAI's repository added four more Jinja2 environments after that fix, each instantiating the unsandboxed default class from the same library, and exposed one of them on an HTTP route guarded by *the caller has a proxy key*. The patch for the HTTP-reachable one was titled to look like routine input validation. The patch for the other three came three weeks later and shipped without a CVE. Every LiteLLM deployment running 1.80.5 through 1.83.6 with `/prompts/test` reachable held provider credentials in a process the lowest-privilege key holder in the organisation could compromise with one Jinja2 expression.\n\nThe Astianjy PoC is dated May 12, six days ago. Its README is in Mandarin; its callback uses CEYE for out-of-band verification. The repository has zero stars. The same shape against the same primitive is in three more files until the consumer upgrades to a version newer than v1.83.7, because v1.83.7 only contains the fix for the dotprompt one.\n\nPoC: [Astianjy/CVE-2026-42203](https://github.com/Astianjy/CVE-2026-42203)","closing_line":"The fix is one import line. The codebase had it in 2024. It took two years and four parallel modules to reach the file with an HTTP route attached.","hook_md":"On April 9, 2026, a developer named jayden committed eight lines to `litellm/integrations/dotprompt/prompt_manager.py`. The commit title was *fix(proxy): improve input validation on management endpoints*. The diff replaces `from jinja2 import DictLoader, Environment, select_autoescape` with `from jinja2.sandbox import ImmutableSandboxedEnvironment` and changes `self.jinja_env = Environment(` to `self.jinja_env = ImmutableSandboxedEnvironment(`. The commit title does not contain the words *SSTI*, *RCE*, *template injection*, or *sandbox*. The inline code comment, three lines below the import, does: *templates can come from user input via /prompts/test, so we must block access to unsafe Python attributes*. Three weeks and twenty-three days later, on May 2, a different developer committed the same change to three other files. CVE-2026-42203 was assigned to one of the four.","post_id":300,"slug":"litellm-cve-2026-42203-one-of-four-jinja2-managers","title":"CVE-2026-42203: LiteLLM Patched One of Four Jinja2 Prompt Managers","type":"initial","unreadable_sentence":"BerriAI's own co-founder committed `ImmutableSandboxedEnvironment` to `factory.py` in April 2024. The four prompt managers added after that commit each imported plain `Environment` from the same library."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCavZwNQAKCRDeZjl4jgkQ JuOQAP9NM+wvARTY7txFlDnC2TSoGt8RpGol0VJ+MJV5OWOzxAD9GDxPiEwzNHR/ AorxdoHzHRZdrmXClui1WA7u7AqQxAQ= =zy9l -----END PGP SIGNATURE-----