Server-side template injection (SSTI) is one of those vulnerability classes that turns a simple string interpolation mistake into arbitrary code execution. Unlike XSS — where the injected code runs in a victim’s browser — SSTI runs on your server, with the permissions of your web process. In April 2026, CVE-2025-47411 (CVSS 9.8) in Langflow’s template evaluation path demonstrated again that SSTI remains one of the most consistently underestimated vulnerability classes in production applications.
How Template Injection Works
Modern web applications use template engines to render dynamic content. The engine takes a template string with placeholders and fills them with data. The vulnerability arises when user-supplied input is passed directly into the template string itself rather than into the data context.
Safe pattern — user input goes into the data context:
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape())
template = env.from_string("Hello, {{ name }}!")
result = template.render(name=user_input) # safe: user_input is data
Vulnerable pattern — user input is part of the template:
from jinja2 import Environment
env = Environment()
template_string = f"Hello, {user_input}!" # DANGEROUS: user_input in template
template = env.from_string(template_string)
result = template.render()
The second pattern evaluates user_input as Jinja2 template syntax. An attacker who submits {{ 7*7 }} receives Hello, 49! — confirming template evaluation. From there, the path to RCE is straightforward.
Exploitation Across Template Engines
Jinja2 (Python)
Jinja2’s sandbox is frequently bypassed. The standard RCE payload uses Python’s object introspection to navigate from any class to subprocess.Popen:
# Detection payload -- confirms evaluation
{{ 7*7 }}
# Expected: 49
# RCE via class hierarchy traversal
{{ ''.__class__.__mro__[1].__subclasses__() }}
# Lists all Python subclasses -- find index of subprocess.Popen
{{ ''.__class__.__mro__[1].__subclasses__()[<N>]('id', shell=True, stdout=-1).communicate() }}
# Executes shell command
Twig (PHP)
Twig evaluates template expressions within {{ }} and {% %} blocks. When user input reaches the template constructor, the exploit path uses Twig’s filter chain:
# Detection
{{7*7}}
# → 49
# RCE in Twig (exploits _self global)
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
Freemarker (Java)
Freemarker’s freemarker.template.utility.Execute class can be reached through object wrappers:
# Detection
${7*7}
# RCE
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
Real Exploitation Impact
The Langflow CVE-2025-47411 is a textbook example: Langflow’s LLM flow builder evaluated user-supplied template strings to allow dynamic prompt construction. An unauthenticated attacker could send a crafted POST request to the /api/v1/run/predict endpoint with Jinja2 template syntax, achieving RCE on the application server. Given that Langflow deployments often have access to LLM API keys, vector databases, and cloud credentials, the impact was severe.
A similarly structured vulnerability in E-commerce personalisation templates (user-controlled “welcome message” fields rendered through Jinja2) is one of the most common SSTI discoveries in real penetration tests. The developer intended to allow markdown formatting; the template engine supports much more.
Prevention
Rule 1: Never evaluate user input as template code
This is the root cause fix. Template engines distinguish between templates (code) and context data (values). User input must always land in the data context, never in the template string.
# Python/Jinja2 -- correct approach
template = env.from_string("Dear {{ first_name }} {{ last_name }},")
rendered = template.render(
first_name=request.form['first_name'], # data context -- safe
last_name=request.form['last_name']
)
# Wrong -- never do this
template_str = f"Dear {request.form['first_name']} {request.form['last_name']},"
rendered = env.from_string(template_str).render() # user in template string
Rule 2: Use sandboxed environments where dynamic templates are required
If your use case genuinely requires user-defined template logic (e.g., a user configuring their own email templates), use Jinja2’s SandboxedEnvironment and restrict available attributes:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment()
try:
template = env.from_string(user_provided_template)
result = template.render(allowed_data_only=data)
except Exception as e:
# Template evaluation failed -- log and reject
raise ValidationError("Invalid template syntax")
Note: Jinja2’s sandbox is not a security guarantee. It raises the bar significantly but has documented bypass techniques for determined attackers. Sandboxed environments should be treated as a defence-in-depth measure, not the primary control.
Rule 3: Allow-list template syntax if templates must be user-defined
If users genuinely need to define templates, consider a purpose-built “safe template” system that only supports a limited set of operations — string interpolation only, no filter chains, no object access:
import re
SAFE_VARIABLE_PATTERN = re.compile(r'\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}')
def safe_render(template: str, context: dict) -> str:
"""Render only simple {{variable}} substitutions -- no Jinja2 evaluation."""
def replace_var(match):
var_name = match.group(1).strip()
return str(context.get(var_name, ''))
return SAFE_VARIABLE_PATTERN.sub(replace_var, template)
This approach uses no template engine at all — just regex substitution — eliminating the SSTI surface entirely while still supporting user-defined variable substitution.
Rule 4: Validate and reject template metacharacters at input
As a defence-in-depth measure, reject inputs containing common template metacharacters when user input isn’t expected to contain template syntax:
import re
TEMPLATE_METACHAR_PATTERN = re.compile(r'\{\{|\}\}|\{%|%\}|\$\{|<#')
def contains_template_syntax(value: str) -> bool:
return bool(TEMPLATE_METACHAR_PATTERN.search(value))
if contains_template_syntax(user_input):
raise ValidationError("Input contains invalid characters")
Detection in Code Review
Flag these patterns in any code review:
env.from_string(f"...{user_var}...")— f-string interpolation into template sourceTemplate(user_input).render()— user string passed directly to Template constructor- Any function that takes user input and passes it to
render(),process(), orevaluate()methods of a template engine - E-commerce “customisation” features that allow users to define message templates
SSTI is reliably found wherever developers add user-facing customisation — email templates, notification messages, report layouts, and prompt construction in LLM applications. The pattern is always the same: user input that was originally intended as data drifts into being evaluated as code.