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.
- 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.
- 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.
- 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.
- Keep config, request, g and os out of the render context; expose just the notification data.
- 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.
- 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.
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
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
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
# 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
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 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.
# 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)- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.flask.security.audit.render-template-string.render-template-string | Flagged 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) |
| Semgrep | python.flask.security.dangerous-template-string.dangerous-template-string | No 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) |
| CodeQL | py/template-injection | The 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 |
| CodeQL | py/code-injection | Companion 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 |
| Bandit | B701jinja2_autoescape_false | No 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) |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 (Jinja2 3.1.6) |
| flask | 3.1.3 |
| jinja2 | 3.1.6 |
| markupsafe | 3.0.4 |
| werkzeug | 3.1.9 |
| pytest | 9.1.1 |
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.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# 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 pytestEach 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.
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
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
- Jinja2: sandbox (SandboxedEnvironment, SecurityError)
- Flask API: render_template_string
- Jinja2: template designer documentation (expressions, filters, attribute access)
- OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
- PortSwigger Web Security Academy: Server-side template injection
- OWASP Top 10:2025 A05 Injection
Attack patterns: