
A self-contained, reproducible environment demonstrating CVE-2025-23211, a server-side template injection (SSTI) vulnerability in Tandoor Recipes that leads to remote code execution. The repository stands up the vulnerable version, shows the exploit, then stands up the patched version and shows the same input being rejected.
| CVE | CVE-2025-23211 |
| Advisory | GHSA-r6rj-h75w-vj8v |
| Class | Server-Side Template Injection (Jinja2), CWE-94 / CWE-1336 |
| CVSS 3.1 | 9.9 (Critical) - AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
| Affected | Tandoor Recipes <= 1.5.23 |
| Fixed in | 1.5.24 |
Tandoor Recipes is an open-source, self-hosted meal-planning and recipe-management application. The backend is Django (Python) served by Gunicorn, the frontend is a Vue.js single-page application, and data is stored in PostgreSQL.
Tandoor lets users write Jinja2 template expressions inside a recipe's instruction text, a feature intended for tasks such as scaling ingredient quantities. Those instructions are rendered on the server.
The instruction text is processed by render_instructions() in
cookbook/helper/template_helper.py. In affected versions the text is passed to
Jinja2's default Template() and rendered without a sandbox:
template = Template(instructions)
instructions = template.render(ingredients=ingredients, scale=scale)
Because the default Jinja2 environment exposes Python's object graph, an
authenticated user who can edit a recipe can inject a template expression that
walks from an empty tuple to subprocess.Popen and executes an arbitrary command
on the server. The default container runs as root, so the command runs as root.
Instructions pass through a Markdown step before Jinja2 renders them, and that step
strips raw underscores. The exploit therefore hex-encodes underscores (\x5f\x5f)
and uses the attr() filter instead of dot notation. See payloads.txt.
compose-vulnerable.yml Tandoor 1.5.23 (vulnerable) plus PostgreSQL
compose-patched.yml Tandoor 1.5.24 (patched) plus PostgreSQL
payloads.txt The two proof-of-concept payloads and expected output
enumerate-popen.txt Helper for finding the subprocess.Popen index on other images
The evidence in this repository was captured on the setup below. Any recent Docker release with Compose v2 or later reproduces the same result.
Start the vulnerable stack:
docker compose -f compose-vulnerable.yml up -d
Wait until the web container logs show a worker booting, then open
http://localhost:8080.
Register the first account. The first user becomes the superuser. Set a Space name when prompted.
Create a new recipe. In the Steps > Instructions field, paste Payload 1 from
payloads.txt, then save.
Open the recipe to view it. The rendered instructions show
<class 'subprocess.Popen'>, confirming the injection reaches Python internals.
Repeat with Payload 2. Viewing the recipe now runs whoami and displays root.
Confirm the process user directly:
docker compose -f compose-vulnerable.yml exec web_recipes whoami
Stop the vulnerable stack:
docker compose -f compose-vulnerable.yml down
Start the patched stack:
docker compose -f compose-patched.yml up -d
Repeat the same steps with the same payloads. Instead of executing, the recipe
view now shows Could not parse template code. Error: Undefined Error.
To reset everything and start from an empty database, run
docker compose -f compose-vulnerable.yml down -v. The -v flag removes the
named volumes, so the next run recreates the database from scratch.
The patch (commit e6087d5) replaces the unsandboxed Template() with Jinja2's
SandboxedEnvironment:
env = SandboxedEnvironment()
instructions = env.from_string(instructions).render(ingredients=ingredients, scale=scale)
The sandbox overrides Jinja2's attribute lookup to forbid access to unsafe
attributes such as __class__, __base__, __subclasses__, and __getitem__.
Without those, the payload has no path from a plain object to subprocess.Popen,
so the expression fails instead of executing.
The exploit reaches attributes through the attr() filter rather than dot notation,
so in the patched code the blocked access surfaces as an UndefinedError rather
than a SecurityError; both are handled by the sandboxed version.
This repository reproduces a publicly disclosed and already-patched vulnerability for educational purposes. Run it only against the local containers provided here. Do not target systems you do not own or are not authorized to test.