AuditCoreAuditCore
criticalCWE-94OWASP A03:2021 — InjectionSSTI

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. 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. 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 2
    from 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. 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. 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. 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.

FAQ

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 `&lt;`). 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.

Don't just guess — scan and verify

AuditCore Free Trial scans your homepage for Server-Side Template Injection (SSTI) and 50+ other vulnerability classes. No credit card. Results in 60 seconds.