CVE-2025-47411 and the Class: Server-Side Template Injection (SSTI) Prevention

Server-side template injection lets attackers execute arbitrary code by injecting template syntax into inputs that get evaluated by the server's template engine. Here's how it works across Jinja2, Twig, and Freemarker -- and how to prevent it.

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 source
  • Template(user_input).render() — user string passed directly to Template constructor
  • Any function that takes user input and passes it to render(), process(), or evaluate() 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.