Skip to content

VNX-PY-012: Python server-side template injection

Detect request data (directly, through an assignment, a route parameter or a formatted string) used as the template source of render_template_string(), from_string() or a Jinja2, Mako, Django or Tornado Template(), which lets an attacker run template code on the server.

Updated 02 Nov 2026Checked against cli@de9b52a
Field Value
Rule ID VNX-PY-012
Severity Critical
SARIF level error
Kind sast
Languages Python
CWE CWE-1336
CAPEC CAPEC-242
ATT&CK T1190
CWSS vector CWSS:1.0/TI:C/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:C/DI:H/EX:H/EC:N/P:W
Tags ssti template-injection flask jinja2
Source internal/sast/rules/vnx-py-012.rego

Overview

This rule flags Python code that uses render_template_string() or jinja2.Template() with values derived from user input, specifically f-strings, request attributes, or string concatenation passed as the template argument. Server-side template injection (SSTI) occurs when user input is interpreted as template syntax rather than as data. Because Jinja2 templates can access Python internals, a successful SSTI payload gives an attacker full remote code execution on the server. This maps to CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine.

Default behavior: Flask’s render_template_string() does NOT sandbox the template expression. Any template syntax in the string is executed with full Jinja2 permissions. Passing user input as the template string, rather than as a context variable, is insecure by design; there is no flag to make it safe.

Why This Matters

Jinja2 templates execute Python-like expressions inside {{ }} and {% %} blocks. When user input becomes part of the template string itself (rather than a variable passed to a static template), the attacker can inject template syntax. A classic probe is {{7*7}}, if the server responds with 49 instead of the literal string {{7*7}}, the endpoint is confirmed vulnerable.

From there, Jinja2’s sandbox is weak. An attacker can traverse Python’s object hierarchy to reach os.popen or subprocess:

{{ config.items() }}                     # leaks SECRET_KEY and all config
{{ request.application.__globals__ }}   # access Flask's global namespace
{{ ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__['os'].popen('id').read() }}

Unlike XSS, SSTI does not require a victim user to be present, the attacker exploits the server directly. The impact is full server compromise: arbitrary code execution with the application’s privileges, access to all environment variables and secrets, and the ability to read or modify any file the process can reach.

What Gets Flagged

The rule matches patterns where user input flows directly into render_template_string or Template as the template itself.

from flask import request, render_template_string

# FLAGGED: f-string with user input as the template
@app.route("/greet")
def greet():
    name = request.args.get("name", "World")
    return render_template_string(f"<h1>Hello {name}!</h1>")
    # Attacker sends: ?name={{config}}  → leaks Flask SECRET_KEY

# FLAGGED: request attribute as the template
@app.route("/preview", methods=["POST"])
def preview():
    return render_template_string(request.form["template"])

# FLAGGED: Jinja2 Template with f-string of a route parameter
from jinja2 import Template
@app.route("/card/<content>")
def render_user_content(content):
    return Template(f"<div>{content}</div>").render()

# NOT flagged: constant template, user value passed as context
return render_template_string("<h1>{{ name }}</h1>", name=request.args["name"])

# FLAGGED: string concatenation in render_template_string
@app.route("/hello")
def hello():
    return render_template_string("<p>Hello " + request.args.get("name") + "</p>")

Remediation

  1. Use render_template() with a static template file. This is the correct pattern. The user’s data is passed as a variable to a pre-written template, not as the template itself. Template syntax in the variable value is not executed:
from flask import render_template, request

@app.route("/greet")
def greet():
    name = request.args.get("name", "World")
    # SAFE: name is a variable passed to a static template
    return render_template("greet.html", name=name)
{# templates/greet.html, Jinja2 escapes {{ name }} automatically (Flask default) #}
<h1>Hello {{ name }}!</h1>
  1. If you need to render user-provided template fragments, use a sandboxed environment. Jinja2 provides a SandboxedEnvironment that restricts attribute access. It is not a complete security boundary but significantly raises the bar:
from jinja2.sandbox import SandboxedEnvironment

sandbox_env = SandboxedEnvironment(autoescape=True)

def render_user_template(template_string: str, context: dict) -> str:
    try:
        tmpl = sandbox_env.from_string(template_string)
        return tmpl.render(**context)
    except Exception:
        return ""  # Do not leak error details to users
  1. For email templates, notification messages, or user-configurable content, use a restricted template language. Libraries like chevron (Mustache) or string.Template provide variable substitution without template logic execution:
import string

# SAFE: string.Template only substitutes $variables, does not execute code
template = string.Template("Hello $name, your order $order_id is ready.")
message = template.safe_substitute(name=user.name, order_id=order.id)
  1. Never expose {{ config }} or {{ request }} in templates. Even in static template files, avoid rendering the Flask config or request objects directly, they contain sensitive data. Pass only the specific values a template needs.

References

Suppressing this rule

Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):

# nosec vnx-py-012
// nosec vnx-py-012

Or record an ignore rule: vulnetix ignore add --rule vnx-py-012 --reason "...". See Suppressing findings.

Edit this pageSources: cli/internal/sast/rules/vnx-py-012.rego, cli/internal/sast/docs/vnx-py-012.md
esc
↑ ↓ move↵ openSearches in your browser. A key like autonomy.max or a code like threshold_required jumps straight to it.