← News
/PLAYBOOK · 2026-09-13

SSTI Template Injection Defense: Jinja2 Sandbox Configuration

SSTI renders user input as template source—impact equals RCE. Full defense chain: Flask minimal repro, Jinja2 vs Twig/Freemarker fingerprinting, SandboxedEnvironment hardening, input allowlists and render isolation, ModSecurity delimiter rules, audit grep, and config verification.

Server-Side Template Injection (SSTI) occurs when the server renders user input as template source. Impact typically equals RCE. This article covers the full SSTI defense chain: minimal repro, multi-engine fingerprinting, Jinja2 sandbox hardening, input allowlists and render isolation, code audit and WAF rules, plus config verification and troubleshooting.

Scope

Prerequisites

How it works

A template engine replaces placeholders in template text with data, then emits the final text. Placeholders are evaluated as expressions at render time: Jinja2 uses `{{ }}`, Twig is close to Jinja2, Freemarker uses `${ }`, Velocity uses `$!{ }`.

The failure is a broken boundary between "template text" and "data". When code is written as `render_template_string("Hello " + name)`, `name` lands in the template-text position. Submitting `{{7*7}}` yields `49`. Expression chains then walk object attributes and eventually call dangerous methods such as `os.popen` for command execution. This is not XSS: XSS executes in the browser; SSTI executes on the server, so a successful exploit runs as the web process.

Typical attack chain, four steps: probe the render point (submit arithmetic, check evaluation) → read context (`{{config}}`, `{{self}}`) → hunt usable objects (walk `__class__.__mro__` to the base class, enumerate subclasses) → invoke an execution primitive for command execution or file read. Defense is not filtering a payload string. Cut the data flow that lets user input enter template source.

Procedure

Step 1: Minimal repro environment

python3 -m venv /opt/ssti-lab/venv
/opt/ssti-lab/venv/bin/pip install flask jinja2
mkdir -p /opt/ssti-lab && cd /opt/ssti-lab

Write the vulnerable sample app_vuln.py. Note the string concat on line 3:

from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/greet")
def greet():
    tpl = "Hello, " + request.args.get("name", "guest") + "!"
    return Template(tpl).render()   # Dangerous: user input enters template source

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000)
/opt/ssti-lab/venv/bin/python app_vuln.py &
curl -s "http://127.0.0.1:5000/greet?name=%7B%7B7*7%7D%7D"

Response `Hello, 49!` confirms SSTI. Literal `{{7*7}}` is a safe implementation. Use `{{7*'7'}}` to fingerprint the engine: Jinja2 returns `7777777` (string repeat), Twig returns `49` (arithmetic only).

Step 2: Identify the template engine

Defense location and fix differ by engine. Confirm the engine before changing code. Common probe expressions:

Step 3: Replace native render with a sandboxed engine

Replace the dangerous pattern with a controlled one. Three rules: do not concatenate template source, do not grant user-controlled template capability, if dynamic templates are required add sandbox plus allowlist. Fixed app_safe.py:

from flask import Flask, request
from jinja2.sandbox import SandboxedEnvironment

app = Flask(__name__)
env = SandboxedEnvironment()          # Sandboxed environment; dangerous attributes blocked by default
env.globals.clear()                    # Clear default globals such as cycler/joiner
env.filters.clear()                    # Re-add business filters as needed

# Fixed template + variable passing: user input is data only, never template source
TPL_GREET = env.from_string("Hello, {{ nickname }}!")

@app.route("/greet")
def greet():
    nick = request.args.get("name", "guest")
    return TPL_GREET.render(nickname=nick)

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=5000)

After the fix, `curl "http://127.0.0.1:5000/greet?name={{7*7}}"` returns `Hello, {{7*7}}!`. The expression is emitted as a plain string.

Step 4: Input allowlist and render isolation

import re
ALLOWED = re.compile(r"^[\w\u4e00-\u9fff \-]{1,64}$", re.UNICODE)

def safe_text(v: str) -> str:
    return v if ALLOWED.match(v) else "guest"

Step 5: WAF rules (Nginx + ModSecurity)

Confirm ModSecurity is loaded with `nginx -V 2>&1 | grep -o modsecurity`, then append a custom rule covering template delimiter signatures:

# /etc/nginx/modsec/custom-ssti.conf
SecRule ARGS "@rx (\{\{|\}\}|\$\{|\{%|%\}|<%|%>)" \
  "id:10001,phase:2,deny,status:403,log,msg:'SSTI template delimiter detected',\
   tag:'attack-ssti',severity:'CRITICAL'"
nginx -t && systemctl reload nginx

This rule denies template delimiters in parameters. Last-line defense, not a substitute for a code fix. Features that legitimately submit `{{ }}` (code editors, Markdown preview) need a path allowlist.

Step 6: Bulk code audit

grep -rnE "render_template_string|from_string\(|Template\(" --include=*.py /var/www
grep -rnE "createTemplate|Twig\\Environment|fromString" --include=*.php /var/www
grep -rnE "new Template\(|Configuration\(\)|getTemplate\(" --include=*.java /var/www

Review each hit: does template text contain a user-controlled fragment? Only calls that concatenate user input into template text are vulns. Fixed template text with user-supplied variables only is safe.

Verification

FAQ

FAQ 1: Is filtering `{{ }}` enough?

No. Template delimiters can be encoded or bypassed via other syntax (Freemarker `<#assign>`, Velocity `#set`). Aggressive blacklists also break legitimate features. Correct order: first eliminate the data path that puts user input into template source, then use delimiter signatures as defense in depth.

FAQ 2: Does `SandboxedEnvironment` prevent all bypasses?

Not absolutely. Jinja2 sandbox has had multiple historical escapes, e.g. attribute chains or filters reaching unprotected internals. In production: upgrade Jinja2 to 3.1+, clear `env.globals` and unused `filters`, rewrite `is_safe_attribute` to allowlist-only if needed, and drop render-process privileges to the minimum.

FAQ 3: Can frontend frameworks have the same issue?

Yes. Client-Side Template Injection (CSTI). In Vue, Angular, etc., handing user input to `v-html` or `compile()`-style dynamic compile interfaces lets crafted expressions execute. Same defense: user input is data binding only, never enters the template compile path.

FAQ 4: After the fix, how do we confirm it was never exploited?

Search web access logs for template delimiter signatures: `grep -Ei "%7B%7B|\{\{|\$\{" /var/log/nginx/access.log | awk '{print $1,$7}' | head`. Verify hit source IPs with the business owner. Also check the web-process account for unexpected child processes and temp files.

Summary

Root cause of SSTI is a collapsed boundary between template source and user data. Impact is server process privilege. Defense has three layers: code — replace string concat with a fixed template plus variable passing; sandbox and allowlist for dynamic templates. Runtime — upgrade the engine, minimize sandbox-reachable objects, cap render timeout and process privileges. Perimeter — WAF delimiter rules and log-signature search for fallback and forensics. All three layers together keep this class of injection in a controllable range.