Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix server-side template injection in Python and Flask

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Pass input as data, never as template text: render_template_string('Hello {{ name }}', name=name), not render_template_string('Hello ' + name). Where users author templates, render them with a jinja2.sandbox.SandboxedEnvironment and put only the data they need in scope, not config, request or os. Do not denylist {{ or __class__; {{ config }} and |attr gadgets slip past it.

On this page

Steps

  1. Find every render_template_string, Environment.from_string or Template(...) call whose template text is built from request data (request.args, request.form, request.json), including in helper modules.
  2. When the input is a value, keep the template text constant and pass the value as a keyword argument; autoescaping then keeps it inert in the HTML.
  3. When users genuinely author templates, render them with jinja2.sandbox.SandboxedEnvironment and pass only the data the template needs; catch SecurityError and TemplateError and return 400.
  4. Keep config, request, g and os out of the render context; expose just the notification data.
  5. Add regression tests with this card's payloads (a {{ config }} data leak and a cycler.__init__.__globals__ code-execution gadget) next to functional tests for legitimate templates and non-ASCII data.
  6. Turn debug mode off in production and log engine errors server-side; the debugger page and raw errors leak secrets and help tune a payload.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.pyPython
def render_notification(template: str, context: dict) -> str:
    # The whole user-supplied template is compiled with the live Flask/Jinja
    # globals (config, request, g, url_for, cycler, lipsum, ...), so an
    # expression can reach them.
    return render_template_string(template, **context)

With IMPL=vulnerable, 5 of 11 tests fail (5 exploit tests).

The fix: fixed template and data

Fixedimpl/fixed.pyPython
def greeting(name: str) -> str:
    # Constant template text; the name is data and is autoescaped, so a value
    # like "{{ config }}" is printed literally instead of being evaluated.
    return render_template_string("<p>Hello {{ name }}!</p>", name=name)

With IMPL=fixed, 11 of 11 tests pass.

The fix: sandbox for user-authored templates

Fixedimpl/fixed.pyPython
# A sandbox is the right tool only when users genuinely author templates. It
# intercepts attribute access and blocks the gadgets SSTI relies on; the render
# context carries just the notification data, never config/request/os.
_SANDBOX = SandboxedEnvironment(autoescape=True)


def render_notification(template: str, context: dict) -> str:
    return _SANDBOX.from_string(template).render(**context)

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def render_notification(template: str, context: dict) -> str:
    _reject_blocked(template)  # same denylist; see _BLOCKED above
    return render_template_string(template, **context)

With IMPL=wrong, 3 of 11 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_render.pyPython
# Exploit: each payload must stay inert; the secret marker must never appear.

def test_exploit_greeting_config_data_access(client, fixture):
    payload = "{{ config['SSTI_SECRET'] }}"
    res = client.get("/greeting", query_string={"name": payload})
    assert fixture["marker"] not in body_text(res)


def test_exploit_greeting_globals_code_execution(client, fixture):
    payload = "{{ cycler.__init__.__globals__.os.environ['SSTI_SECRET'] }}"
    res = client.get("/greeting", query_string={"name": payload})
    assert fixture["marker"] not in body_text(res)


def test_exploit_render_config_subscript_data_access(client, fixture):
    payload = "{{ config['SSTI_SECRET'] }}"
    res = client.post("/render", json={"template": payload})
    assert fixture["marker"] not in body_text(res)


def test_exploit_render_config_attribute_data_access(client, fixture):
    payload = "{{ config.SSTI_SECRET }}"
    res = client.post("/render", json={"template": payload})
    assert fixture["marker"] not in body_text(res)


def test_exploit_render_globals_code_execution(client, fixture):
    payload = "{{ cycler.__init__.__globals__.os.environ['SSTI_SECRET'] }}"
    res = client.post("/render", json={"template": payload})
    assert fixture["marker"] not in body_text(res)

Pitfalls

  • render_template_string compiles its first argument as a template. Passing data through keyword arguments is safe; concatenating or f-string-building that argument from input is injection.
  • SandboxedEnvironment blocks unsafe attribute access (__globals__, __init__, ...) and dangerous callables, but only if you also keep config, request and os out of scope. Give it the data and nothing else.
  • Jinja2's default globals already include cycler, lipsum, range, dict, namespace and joiner; a Flask render_template_string context adds config, request, g and url_for. The classic leak is {{ config }}; the classic RCE walks cycler.__init__.__globals__.os.
  • Autoescaping stops XSS in the output but does nothing against SSTI; a payload is evaluated before there is any output to escape.
  • Denylisting is unwinnable: {{ config }} uses no dunder, {% ... %} works even if you block {{, and {{ ()['__cl' ~ 'ass__'] }} or |attr('__globals__') rebuild a blocked name at render time.
  • Use ImmutableSandboxedEnvironment if user templates may call mutating methods on the data you pass in.
  • render_template (a file on disk) is safe; it is render_template_string and from_string built from input that compile attacker text.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs python-flask does this for you).
  2. Run .venv/bin/semgrep scan --config r/python.flask.security.audit.render-template-string.render-template-string impl and .venv/bin/bandit -r impl; expect the render_template_string hits described in scannerRules (including the audit-rule note on the fixed greeting) and nothing from Bandit.
  3. Against your own endpoint, send a template of {{ config }} and a cycler.__init__.__globals__ gadget and confirm no secret comes back and the sandbox rejects the gadget with a 400.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix server-side template injection in Python and Flask
ToolRuleWhat it found here
Semgreppython.flask.security.audit.render-template-string.render-template-stringFlagged every render_template_string call: 2 in impl/vulnerable.py, 2 in impl/wrong.py and 1 in impl/fixed.py. The fixed.py hit is the greeting's constant template with the value bound as data (safe); it is an audit rule that cannot tell a constant template from one built from input, so review and justify that one.Checked: Semgrep 1.180.0 (CE)
Semgreppython.flask.security.dangerous-template-string.dangerous-template-stringNo findings on any implementation. The rule matches a template string first assigned to a variable with format(), %, += or an f-string and then passed to render_template_string; this card concatenates inside the call ("<p>Hello " + name + "!</p>") or passes the caller's template straight through, so only the audit rule above flags it. (The card first cited python.flask.security.injection.ssti.ssti, which is not in the registry.)Checked: Semgrep 1.180.0 (CE); semgrep scan --no-git-ignore --config r/<id> impl app.py (5 files)
CodeQLpy/template-injectionThe Python Flask library models flask.render_template_string (RenderTemplateStringSummary in semmle/python/frameworks/Flask.qll) and treats its template argument as a template-injection sink, so this query is expected to flag the vulnerable and wrong implementations, where the request value becomes part of the template, under cross-file analysis.Checked: github/codeql main; not executed
CodeQLpy/code-injectionCompanion CWE-094 query for code built from untrusted input (eval/exec and template construction); listed for coverage alongside py/template-injection.Checked: github/codeql main; not executed
BanditB701jinja2_autoescape_falseNo findings. Bandit has no check for render_template_string SSTI; B701 only flags jinja2.Environment(autoescape=False), and this card's sandbox uses autoescape=True. The regression tests are the control here.Checked: Bandit 1.9.4 (bandit -r impl)

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3 (Jinja2 3.1.6)
flask3.1.3
jinja23.1.6
markupsafe3.0.4
werkzeug3.1.9
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the template-injection/python-flaskfolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs template-injection/python-flask runs all three modes and checks the expected results.

Download the project ZIP, 18 KB

Verified

Toolchain
  • python 3.14.8
  • flask 3.1.3
  • jinja2 3.1.6
  • markupsafe 3.0.4
  • werkzeug 3.1.9
  • pytest 9.1.1
Matrix
fixed: 11 of 11 tests pass; vulnerable: 5 of 11 tests fail (5 exploit tests); wrong: 3 of 11 tests fail (3 exploit tests)

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
  2. Jinja2: sandbox (SandboxedEnvironment, SecurityError)
  3. Flask API: render_template_string
  4. Jinja2: template designer documentation (expressions, filters, attribute access)
  5. OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
  6. PortSwigger Web Security Academy: Server-side template injection
  7. OWASP Top 10:2025 A05 Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.