Server-side template injection
There are two different things people mean by “user input in a template”, and only one of them is this page.
A user-supplied value passed into a template is escaped, assuming the extension is on the autoescape list. That is the ordinary case and it is handled.
A template whose source is built from user input is not protected by anything, because escaping applies to values and this is code.
Observed
Section titled “Observed”render_template_string("{{ x }}", x="<script>alert(1)</script>")# -> '<script>alert(1)</script>' value escaped, correct
render_template_string("{{ 7*7 }}")# -> '49' source evaluated
render_template_string("{{ config.SECRET_KEY }}")# -> 'REAL-SECRET-VALUE-abc123' the actual keyThe first line is autoescaping working. The second and third are the same function with the payload moved from the argument to the template, and there is no escaping concept that applies — Jinja was asked to render that expression and it did.
The third line is why this is rated high rather than medium. Flask puts config in the
template context by default, so a template injection is one expression away from
SECRET_KEY, and a key is one step from forging any session.
Where the template source comes from user input
Section titled “Where the template source comes from user input”Rarely as directly as render_template_string(request.args["t"]). The realistic
shapes are:
# a message with the user's name interpolated BEFORE renderingrender_template_string(f"<p>Hello {user.name}, welcome back</p>")
# an admin-editable email body or notification template stored in the databaserender_template_string(row.body_template, order=order)
# a "dynamic page" feature where content is authored in the CMSrender_template_string(page.content)The first is the common accident: an f-string that builds the template before Jinja ever
sees it, so user.name becomes part of the source. A display name of {{ 7*7 }} is the
whole proof of concept.
The second and third are deliberate features, and the people authoring those templates are trusted-ish rather than trusted — an admin account is one phishing away from being an attacker’s account.
from flask import render_template
# pass values as arguments; never build the sourcereturn render_template("greeting.html", name=user.name)If you genuinely need user-authored templates, render them in a sandbox rather than the application environment:
from jinja2.sandbox import SandboxedEnvironment
sandbox = SandboxedEnvironment(autoescape=True)
def render_user_template(source: str, **ctx) -> str: return sandbox.from_string(source).render(**ctx) # no app config in ctxTwo things are doing work there. SandboxedEnvironment blocks attribute access that
reaches into Python internals, which is what turns SSTI into code execution. And passing
an explicit context means config is simply absent — the sandbox raises the bar, and
not handing over the config removes the prize.
Treat the sandbox as a hardening layer rather than a guarantee. It has had escapes, and a feature that lets users write templates is a feature that needs its blast radius kept small regardless.
# every call that takes a template SOURCE rather than a namegrep -rn 'render_template_string\|from_string\|Template(' --include='*.py' . | head -20Each hit needs its first argument traced to a literal. If the argument is an f-string, a
% format, a + concatenation or a database column, that is the finding.
The f-string case is worth its own pass, since it is the one that looks innocent:
grep -rnE 'render_template_string\(\s*f["'"'"']' --include='*.py' . | headThen probe a running instance wherever user-controlled text is echoed back — a display
name, a bio, a comment, an order note. Set the value to {{7*7}} and look for 49:
curl -s https://app.example.com/profile | grep -o '49'49 where you wrote {{7*7}} is a confirmed injection. Nothing appearing means the
value was treated as data, which is correct.
Why {{7*7}} and not something louder
Section titled “Why {{7*7}} and not something louder”It is the standard probe because it is unambiguous and harmless. A literal {{7*7}}
coming back means the string was treated as data; 49 means it was evaluated. Nothing
is read, written or executed either way, so it is safe to run against a system you are
responsible for.
Escalation from there is well documented and not something this page needs to detail. The point of the check is the one bit of information: evaluated, or not.
Replacing render_template_string with render_template means the template has to
exist as a file, which is a real change if the current design stores templates in the
database for non-developers to edit.
Do not remove that capability without a replacement — the feature will come back, and it will come back as the same call. The sandbox above is the intended path: it keeps the feature and narrows what a template can reach.
Moving to a sandbox breaks templates that used anything it blocks, which for user-authored content is usually nothing, and for templates written by your own team can be quite a lot. Render the existing corpus through the sandbox in a test before switching:
for row in db.session.query(Template).all(): try: sandbox.from_string(row.body).render(**sample_ctx) except Exception as e: print(row.id, type(e).__name__, e)That prints exactly which stored templates will start failing.
Related
Section titled “Related”- Template autoescaping — the control that covers values, and why it does not cover this
- SECRET_KEY — what an injection reads first