The Template That Became a Shell
User text passed to render_template_string() is server-side template injection
Published Oct 29, 2026
Executive Summary
A vibe-coded Flask app lets admins edit email templates, rendered with render_template_string(). If a template ever holds user-influenced text, Jinja2 evaluates it as code — {{ config }} leaks secrets and a crafted payload reaches Python itself. The feature treats template source as data, and that is the whole vulnerability.
The Prompt
“Let admins write their own order email templates with placeholders, rendered when an order ships.”
The Flaw
render_template_string() compiles and runs the string as a Jinja2 template. User-influenced template text means the user is writing server-side code: {{ config }} dumps the app's secrets, and a longer payload walks Jinja2's internals into arbitrary Python execution. The placeholders look benign, but the template itself is an execution context.
Risk & Impact
[Security] Whoever controls the template text controls the server. If the 'admin-only' editor is ever reached by a compromised or low-privilege account — or the template is stored unsanitized — it is game over.
Cost: Server-side code execution and full secret disclosure from a text field.
Code Comparison
❌ Vulnerable (AI generated)
❌ Vulnerable (AI generated)
✅ Hardened (production ready)
The Fix
Keep templates as fixed files or constants. User content is data — pass it as an auto-escaped variable, never concatenate it into the template source.
Prevention
- [linter] Bandit (B703/B704) and Semgrep rules flag render_template_string and markupsafe Markup() calls that reach user input.
- [ci] A test that renders a template containing {{ config }} through the feature must not leak any setting — assert the output contains the literal braces.
- [manual] Tell the agent 'templates are fixed files; user text is always a variable' (rule 6), and validate template input against an allowlist (rule 3).
Why This Happened
Root cause: the prompt omitted Rule 3 and Rule 6 — the guardrails that would have caught this. See the full guide →
