How to fix Server-Side Template Injection (SSTI)
SSTI is the path from 'user controls a string in our email template' to remote code execution on your server. The fix isn't escaping — it's never compiling user input as a template. Here's the complete defense for Jinja2, Twig, Freemarker, ERB, and Handlebars.
What is Server-Side Template Injection (SSTI)?
Server-Side Template Injection happens when user input is rendered AS a template (compiled and evaluated) rather than as data passed TO a template. The bug: `template = Template('Hello ' + user_input); template.render(name=user)`. The user's input becomes part of the template source, so `{{7*7}}` evaluates to 49 — and `{{config.__class__.__init__.__globals__['os'].popen('id').read()}}` (Jinja2) executes `id` on the server.
Every template engine has its escape sequence. Jinja2/Django: `{{ }}` and `{% %}`. Twig: same. Freemarker: `${...}` and `<#...>`. ERB: `<%= %>`. Handlebars: `{{ }}`. The escape allows arbitrary code via the engine's introspection features (Python `__class__` chains, Java reflection, Ruby `eval`).
SSTI typically appears in: email templates with user-supplied subject/greeting, reports with user-customizable headers, dynamic CMSes that let editors write template code (sometimes intentional, dangerously), error pages that include user input. The 2017 PortSwigger SSTI cheat sheet has been the standard reference for finding payloads that bypass restricted sandboxes — and the bypasses keep evolving.
What an attacker can do
The concrete impact of leaving Server-Side Template Injection (SSTI) unpatched.
Remote Code Execution
Most template engines expose introspection that reaches `os.system`, `Runtime.exec`, `eval` — full RCE on the server.
Read filesystem / leak secrets
Even sandboxed templates often leak environment variables, file contents, and config.
Server-side request forgery
Template engines can fetch URLs (Jinja2 `urllib`, Freemarker `freemarker.ext.HttpRequestHashModel`).
Bypass authentication
RCE → modify session storage, forge tokens, or drop a backdoor — full app takeover.
How do I know if I'm vulnerable?
Manual: search for template engine APIs that accept strings — `Template(user_input)`, `env.from_string(...)`, `new Environment().createTemplate(...)`. Each one needs proof that the input isn't user-controlled.
Automated: AuditCore's SSTI Scanner sends polyglot probes (`{{7*7}}`, `${{7*7}}`, `<%= 7*7 %>`, `{{= 7*7}}`) and checks responses for the evaluated value (49). Confirmed findings escalate to engine-specific RCE proof. Static analysis (Semgrep) flags `Template(`, `from_string(`, and similar constructors when called with non-literal arguments.
How to fix Server-Side Template Injection (SSTI)
5 ordered steps. Apply them in order — each builds on the previous.
- 1
Don't render user input AS templates — render it INTO templates
Templates are code. User input is data. The fix is keeping them separate.
If you currently have `template_str = 'Hello ' + user_input + ', welcome!'` and render it: STOP. Use `template = 'Hello {{ name }}, welcome!'` (constant) and pass `name=user_input` as a context variable.
pythonStep 1# ❌ User input becomes part of template source — SSTI from jinja2 import Template template = Template(f"Hello {request.args.get('name')}, welcome!") output = template.render() # ?name={{config.__class__.__init__.__globals__['os'].popen('id').read()}} # ✅ Template is constant; user data passed as variable template = Template("Hello {{ name }}, welcome!") output = template.render(name=request.args.get("name")) # Now {{config...}} is rendered as the literal string, not evaluated - 2
Use Jinja2 sandboxed environment if templates are user-supplied
If users legitimately need to write templates (e.g., CMS users authoring email templates), use a sandboxed environment that blocks dangerous attribute access.
`jinja2.sandbox.SandboxedEnvironment` blocks access to private attributes (`__class__`, `__bases__`). It's still bypass-able with effort, so combine with limiting available globals.
pythonStep 2from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment( autoescape=True, finalize=lambda x: x if x is not None else "", ) # Don't expose dangerous globals env.globals.clear() env.globals.update({"len": len, "range": range}) # only what's needed template = env.from_string(user_supplied_template_str) output = template.render(user_data) - 3
Disable template execution on user-rendered output
Frameworks often render template strings in error pages, flash messages, etc. Make sure those are escaped, not interpreted.
Flask flash messages, Django messages framework, Rails flash[] — these are auto-escaped when rendered, but if you concat them into HTML before rendering, you bypass auto-escape.
pythonStep 3# ❌ Flash message gets concatenated into another template flash(f"Hello {request.args.get('name')}") # Then {{ get_flashed_messages()[0] }} — escaped, but if you do: # flash(get_flashed_messages()[0]) # double-render trap # ✅ Always pass user data as variable, never concat into templates flash("Hello %s", request.args.get("name")) # depends on framework - 4
Use logic-less templates (Mustache, Handlebars without helpers)
If you can choose your template engine, pick one without expression evaluation — Mustache deliberately doesn't have `if`/`for` with arbitrary expressions, removing most of the SSTI surface.
Mustache's logic-less philosophy was specifically to avoid template-as-code coupling. For email templates and reports where you don't need full Turing completeness, Mustache is safer by design.
javascriptStep 4// Handlebars with helpers — avoid arbitrary helper registration import Handlebars from "handlebars"; // ❌ Don't allow user-controlled template strings const tpl = Handlebars.compile(userInput); // ✅ If templates must come from users (e.g. CMS authors), // use a strict allow-list of helpers and escape outputs Handlebars.registerHelper("safe-list", (arr) => Array.isArray(arr) ? arr.map(String).join(", ") : ""); // And consider switching to Mustache for user-authored templates - 5
Run template rendering in a separate process with seccomp/AppArmor
If you must run untrusted templates, isolate the renderer. Even sandbox bypasses become harmless if the process can't make syscalls or access the filesystem.
Spawn a subprocess with seccomp filter blocking `execve`, `connect`, `open` (except for the template input). Use containers with `--read-only --network=none --cap-drop=ALL`.
yamlStep 5# Docker for template-renderer service services: template-renderer: image: my-renderer read_only: true network_mode: none cap_drop: [ALL] security_opt: - no-new-privileges:true - seccomp=template-seccomp.json user: "65534:65534" # nobody mem_limit: 128m cpus: 0.5
How to verify the fix
Run AuditCore — SSTI Scanner sends polyglot probes across all 5 supported engines. Verified findings include the evaluated payload as proof.
Manual: in any field that ends up in templated output (email subject, profile bio, report title), test `{{7*7}}`. If output contains `49`, you have Jinja2/Twig/Handlebars SSTI. Try `${7*7}` for Freemarker, `<%= 7*7 %>` for ERB. Confirmed RCE payloads vary by engine — see PortSwigger's SSTI cheat sheet.
Frequently asked questions
Is SSTI the same as XSS?+
No. XSS executes JavaScript in the user's browser; SSTI executes code on your server. SSTI is much worse — full server compromise vs single-user impact.
Does autoescape protect against SSTI?+
No. Autoescape protects against XSS in template OUTPUT (escaping `<` to `<`). SSTI is about user input becoming the template SOURCE — autoescape doesn't apply there.
What's the simplest SSTI test payload?+
`{{7*7}}` for most engines. If response contains `49`, you have SSTI. For ERB it's `<%= 7*7 %>`. For Freemarker `${7*7}`. AuditCore tries all variants.
Can I sandbox Jinja2 securely?+
SandboxedEnvironment blocks the obvious attribute traversal but PortSwigger has documented bypasses periodically. Combine with `env.globals.clear()`, run in subprocess with seccomp, and don't allow user templates if you don't have to.
What's the difference between SSTI and code injection (CWE-94)?+
SSTI is a subclass of code injection — specifically through template engines. CWE-94 is the broader category covering eval(), exec(), pickle.loads(), Function() etc. Same family of bugs; SSTI just has its own canonical payload patterns.