Never compile input as code. Remove eval, new Function, exec and string setTimeout from request paths, and parse data with JSON.parse or json.loads. Render templates that live in your codebase and pass input as variables, so render_template_string(input), ERB.new(input) and createTemplate($input) disappear. Evaluate Spring expressions only with SimpleEvaluationContext, and only expressions you wrote. When users genuinely need logic, such as formulas or custom email templates, give them a restricted language you control: an arithmetic parser over allowlisted fields, a logic-less template engine, or a sandbox whose policy allowlists every tag, filter and method. Keep that sandbox patched and give it no secrets, and run arbitrary user code only in a separate process or container: the in-process sandboxes of Jinja2, Twig and vm2 have all been escaped.
- Template injection is about where the template comes from:
render_template_string('Hello {{ name }}', name=x)is safe, andrender_template_string('Hello ' + x)runs code. - The Node.js documentation says
node:vmis not a security mechanism, and the Python documentation says overriding__builtins__is not one either. ast.literal_evaldoes not run code, but the Python documentation now advises against calling it on untrusted data because small inputs can exhaust memory or the C stack; usejson.loads.- Spring's reference says
StandardEvaluationContextmust never evaluate an expression from an untrusted source, and thatSimpleEvaluationContextrestricts the language only on a best-effort basis. - In-process sandboxes are a layer, not a boundary: Jinja2 fixed sandbox escapes in 3.1.5 and 3.1.6, Twig fixed sandbox bypasses in 3.26 and 3.27, and vm2 published 43 critical advisories in 2026.
- Test with a harmless probe such as
{{ 7 * 7 }},${7*7}or<%= 7 * 7 %>and assert it comes back as literal text.
| Setting | Value | Note | Source |
|---|---|---|---|
| Template source | Files or constants in the codebase; input only as variables | render_template_string, from_string, createTemplate, ERB.new or new Template(...) given input are findings. | OWASP SSTI Prevention Cheat Sheet; ASVS 5.0 V1.3.7 |
| Dynamic code APIs | None on request paths | No eval, new Function, exec, string setTimeout, PHP eval or Ruby eval with input. | ASVS 5.0 V1.3.2 |
| Parsing data | JSON.parse, json.loads, then a schema check | Not eval, and not ast.literal_eval on untrusted input. | Python documentation: ast.literal_eval |
| SpEL evaluation context | SimpleEvaluationContext.forReadOnlyDataBinding().build() | Only for expressions the application wrote. Never StandardEvaluationContext with input. | Spring Framework 7.0 reference: Evaluation |
| Jinja2 user-authored templates | ImmutableSandboxedEnvironment(autoescape=True) on Jinja2 3.1.6 | A separate environment; pass plain values, never objects with side effects. | Jinja documentation: Sandbox |
| Twig user-authored templates | Twig\Sandbox\Sandbox with a strict SecurityPolicy (Twig 3.29 and later) | Its own Environment; allowlist tags, filters, functions, methods and properties. | Twig documentation: Sandbox |
| FreeMarker user-authored templates | new_builtin_class_resolver=allows_nothing, api_builtin_enabled=false | Plus a DefaultObjectWrapper with a WhitelistMemberAccessPolicy. 2.3.x defaults to the unrestricted resolver. | FreeMarker FAQ and Configurable API |
| Velocity with untrusted template authors | introspector.uberspect.class = org.apache.velocity.util.introspection.SecureUberspector | The default UberspectImpl allows object creation and reflection. | Velocity 2.4.1 configuration and developer guide |
| Handlebars prototype access | allowProtoPropertiesByDefault and allowProtoMethodsByDefault left false | The documentation warns that enabling them may let template authors run code. | Handlebars runtime options |
| Untrusted JavaScript | A separate process or container with limits, not node:vm | vm2 says it should not be the only line of defense. | Node.js vm documentation; vm2 README |
- Search the code for
eval(,new Function,setTimeout(",exec(,compile(,render_template_string,from_string,Template(,createTemplate,ERB.new,StandardEvaluationContext,parseExpression,Ognl.andELProcessor, and confirm each takes only constants or developer-written text. - Run your SAST tool's code-injection and template-injection rules (the CodeQL queries named above) and resolve every result.
- For each feature that renders user-influenced text, send the engine's probe (
{{ 7 * 7 }},${7*7},#{7*7},<%= 7 * 7 %>) and confirm the response contains the probe, not49. - Where users author templates or expressions, confirm the restriction setting from the defaults table is in the code, the engine is at or above the fixed versions, and rendering runs with no secrets in reach and with time and size limits.
- Check the dependency manifest for vm2, and for any in-process sandbox library, and confirm it is current and not the only control.
- Applies to
- Jinja2 3.1.6
- Twig 3.29 and later for the Sandbox class; tested on 3.30.0
- Spring Framework (SpEL) 7.0.9; SimpleEvaluationContext since 4.3.15
- FreeMarker 2.3.35
- Apache Velocity Engine 2.4.1
- Handlebars.js 4.7.10
Server-side code in JavaScript, Python, Java, PHP and Ruby that evaluates strings, expression languages or templates. Client-side DOM injection, deserialization and command injection have their own guides.
- Part of
- Learning path: Developer foundations (step 13 of 13). Workflows: Fix a finding, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Tell a template rendered with data from a template compiled from input, and replace eval-style calls with parsers or allowlists.
- Configure SpEL and the main template engines for user-written templates, and explain why their sandboxes are only a layer.
- Write regression tests that prove an expression comes back as text.
Before you start
Developers who build formulas, custom templates, filters or rules into a product, and reviewers who triage code-injection and SSTI findings.
- Know which template engine and expression language your stack uses.
- For the examples: the runtime of the language you pick; Maven for Java, Docker for PHP and Ruby.
Refresh first: Keep untrusted values out of executable syntax
Go to the guided practiceCompiling input versus rendering it
- Input arrivesA customer name of
{{ 7 * 7 }}reaches a feature that sends a shipping email. - Vulnerable: input becomes template sourceThe code concatenates the name into the template text and compiles it with
from_string, so the engine parses the braces as an expression. - The engine evaluates itThe email reads "Hello 49". A real payload walks from a string to Python internals instead.
- Fixed: a constant template, input as dataThe template is compiled once from code, and the name is passed as the variable
name. - Input comes back as textThe email reads "Hello {{ 7 * 7 }}", escaped for HTML. Nothing in the input was parsed.
Code injection (CWE-94) is the general case: the application builds code, or something that is compiled into code, from input. MITRE splits out eval injection (CWE-95) for dynamically evaluated strings, expression language injection (CWE-917) for languages such as SpEL, OGNL and Jakarta EL, and template engine injection (CWE-1336) for server-side template injection (SSTI). The result is the same: the attacker's logic runs inside your process, with its memory, credentials and network access. CWE-94 is tenth in the 2025 CWE Top 25.
The sinks are easy to list, which makes this weakness easy to find. They are the functions that turn a string into code, and the engines that compile a string into a template. ASVS 5.0 states both rules: V1.3.2 asks applications to avoid eval() and dynamic code execution features such as SpEL, and V1.3.7 asks that templates not be built from untrusted input.
Real incidents follow this shape. Spring Cloud Function evaluated a SpEL routing expression a user could supply (CVE-2022-22963, fixed in 3.1.7 and 3.2.3). Confluence Server and Data Center had an OGNL injection that let an unauthenticated attacker run code (CVE-2022-26134). NVD maps Log4Shell (CVE-2021-44228) to CWE-917 too: a lookup expression in a logged string loaded remote code, as the case study at /domains/appsec/vulnerability-response/case-study-log4shell/ explains.
| Language | Sinks that compile or run a string | Use instead |
|---|---|---|
| JavaScript | eval, new Function, setTimeout(string), setInterval(string), vm.runInNewContext | JSON.parse, a parser for your small language, a lookup table of functions |
| Python | eval, exec, compile, jinja2.Template(input), render_template_string(input) | json.loads, an ast-walking evaluator with an allowlist, fixed templates |
| Java | SpEL with StandardEvaluationContext, OGNL, ELProcessor.eval, script engines, FreeMarker or Velocity templates from input | Fixed expressions with SimpleEvaluationContext, maps of allowed fields and operators |
| PHP | eval, assert with a string in old versions, createTemplate($input) in Twig | Fixed templates, the Twig Sandbox for user-authored ones |
| Ruby | eval, instance_eval(string), class_eval(string), ERB.new(input) | Fixed ERB templates, a logic-less engine for user-authored ones |
eval, new Function and setTimeout or setInterval with a string all compile JavaScript at runtime. In Node.js that code can reach process, require through the module system and every credential in the environment. Moving it into node:vm does not help: the Node.js documentation states that the node:vm module is not a security mechanism and must not be used to run untrusted code, because objects passed into a context let code walk back to the host.
vm2 tried to build a sandbox on top of it. It made no release between May 2023 and October 2025, after unpatched escapes were disclosed in July 2023 (CVE-2023-37466 and CVE-2023-37903), and has since resumed. Its README now says that new bypasses will likely be discovered and that it should not be the only line of defense, and its repository lists 43 advisories rated critical published between January and September 2026, many of them sandbox escapes. If users must run their own JavaScript, run it in a separate process, container or VM with no secrets and tight limits.
Most features that reach for eval need much less: a formula over a few fields, a filter, a sort order. The fixed version below parses numbers, allowlisted field names, four operators and parentheses, and nothing else; strings, property access, calls and globals are syntax errors. Run node --test; all four tests pass on Node.js 26.11.1. In browsers, a Content Security Policy without 'unsafe-eval' blocks eval and new Function (/domains/appsec/browser-security/security-headers/).
// A "computed column" feature: users type a formula over a row's fields.
// Vulnerable: the formula is compiled as JavaScript and runs with full access to the process.
export function evaluateVulnerable(formula, row) {
return new Function('row', `with (row) { return (${formula}); }`)(row);
}
// Fixed: a small parser that knows numbers, allowed field names, + - * / and parentheses.
const TOKEN = /\s*(?:(\d+(?:\.\d+)?)|([A-Za-z_][A-Za-z0-9_]*)|([-+*/()]))/y;
export function evaluate(formula, row, allowedFields) {
if (formula.length > 200) throw new Error('formula too long');
const tokens = [];
TOKEN.lastIndex = 0;
while (TOKEN.lastIndex < formula.length) {
const start = TOKEN.lastIndex;
const m = TOKEN.exec(formula);
if (!m) {
if (/^\s*$/.test(formula.slice(start))) break;
throw new Error(`unexpected character at ${start}`);
}
tokens.push(m[1] ? {num: Number(m[1])} : m[2] ? {name: m[2]} : {op: m[3]});
}
let i = 0;
const peek = () => tokens[i];
const take = op => (tokens[i]?.op === op ? (i++, true) : false);
function primary() {
const t = tokens[i++];
if (!t) throw new Error('unexpected end');
if ('num' in t) return t.num;
if ('name' in t) {
if (!allowedFields.includes(t.name) || !Object.hasOwn(row, t.name)) throw new Error(`unknown field ${t.name}`);
const v = row[t.name];
if (typeof v !== 'number') throw new Error(`field ${t.name} is not a number`);
return v;
}
if (t.op === '(') {
const v = sum();
if (!take(')')) throw new Error('missing )');
return v;
}
if (t.op === '-') return -primary();
throw new Error(`unexpected ${t.op}`);
}
function product() {
let v = primary();
for (;;) {
if (take('*')) v *= primary();
else if (take('/')) v /= primary();
else return v;
}
}
function sum() {
let v = product();
for (;;) {
if (take('+')) v += product();
else if (take('-')) v -= product();
else return v;
}
}
const value = sum();
if (peek()) throw new Error('unexpected trailing input');
return value;
}eval and exec run arbitrary code; their documentation says so in a warning. Passing a globals dictionary without builtins does not change that: the documentation says overriding __builtins__ is not a security mechanism, because the code can walk from any object, even an empty string, to the rest of the interpreter.
ast.literal_eval evaluates only literals, but its documentation no longer calls it safe: small inputs can exhaust memory or the C stack and crash the process, and it says calling it on untrusted data is not recommended. Use json.loads for data. For formulas, parse with ast.parse(mode="eval") and walk the tree yourself, allowing only the node types you mean, as the fixed total function does. Exponentiation is left out on purpose: 9 ** 9 ** 9 is a one-line denial of service.
import ast
import operator
def total_vulnerable(expr: str) -> float:
return eval(expr) # runs any Python expression
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def total(expr: str, variables: dict[str, float]) -> float:
"""Evaluate + - * / and parentheses over numbers and named variables, nothing else."""
if len(expr) > 200:
raise ValueError("expression too long")
tree = ast.parse(expr, mode="eval")
def ev(node: ast.AST) -> float:
if isinstance(node, ast.Expression):
return ev(node.body)
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
return node.value
if isinstance(node, ast.Name) and node.id in variables:
return variables[node.id]
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](ev(node.left), ev(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](ev(node.operand))
raise ValueError(f"not allowed: {type(node).__name__}")
return ev(tree)Expression languages are designed to call methods, create objects and reference types, which is why they appear in so many critical advisories. In SpEL, T(java.lang.Runtime) reaches any class and new constructs any object. Spring's reference for Spring Framework 7.0 is explicit: StandardEvaluationContext exposes the complete language and must never be used to evaluate an expression obtained from an untrusted source. SimpleEvaluationContext excludes type references, constructors and bean references, and forReadOnlyDataBinding() allows only property reads; the same page warns that this restriction is best-effort and does not guarantee safe evaluation.
So the order of preference is: no expression language for input at all (map a request's sort or filter choice to code, as filter does below); then SimpleEvaluationContext for expressions your own code or configuration wrote; never StandardEvaluationContext with anything a user can influence. The same reasoning covers OGNL (Struts and Confluence advisories), Jakarta EL's ELProcessor.eval, MVEL and JEXL. The tested Spring fix card at /fix/template-injection/ applies SimpleEvaluationContext to a SpEL template feature. The program below ran with spring-expression 7.0.9: the type reference runs under StandardEvaluationContext and is rejected under SimpleEvaluationContext, along with the constructor.
package example;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public final class ProductFilter {
public record Product(String name, String category, double price) {}
private static final ExpressionParser PARSER = new SpelExpressionParser();
// Vulnerable: the full language, including T(...) type references and constructors.
static Object evaluateVulnerable(String expression, Product product) {
return PARSER.parseExpression(expression).getValue(new StandardEvaluationContext(product));
}
// Safer: only read access to the root object's properties. Still not a sandbox, so the
// expression shape is also restricted to an allowlisted field and operator.
static Object evaluateRestricted(String expression, Product product) {
EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
return PARSER.parseExpression(expression).getValue(context, product);
}
// Best: no expression language at all. The request picks a field and an operator from fixed lists.
private static final Map<String, java.util.function.Function<Product, Double>> FIELDS =
Map.of("price", Product::price);
static Predicate<Product> filter(String field, String op, double value) {
var getter = FIELDS.get(field);
if (getter == null) throw new IllegalArgumentException("unknown field");
return switch (op) {
case "lt" -> p -> getter.apply(p) < value;
case "gt" -> p -> getter.apply(p) > value;
default -> throw new IllegalArgumentException("unknown operator");
};
}
}Every server-side template engine compiles template text into code, and most expose enough of the host language that template text equals code execution. The bug is never "rendering user input": engines are built to render input safely as variables, with escaping. The bug is letting input become part of the template text, usually by string concatenation or formatting before the engine sees it, or by a feature that lets users write templates.
In the vulnerable function below, the name is concatenated into the template, so {{ 7 * 7 }} renders as 49 and {{ ''.__class__.__mro__ }} returns Python's class hierarchy, the usual first step of a Jinja2 exploit. In the fixed function the template is a constant compiled once, and the same input comes back as text. Autoescaping is on for the string template too, because a name is also an XSS vector. The Flask fix card at /fix/template-injection/ shows the same fix in a request handler.
from jinja2 import Environment, select_autoescape
from jinja2.sandbox import ImmutableSandboxedEnvironment
# Application templates: written by developers, loaded from code or files.
app_env = Environment(autoescape=select_autoescape(default_for_string=True, default=True))
SHIPPED = app_env.from_string("Hello {{ name }}, order {{ order_id }} has shipped.")
def shipped_vulnerable(name: str, order_id: str) -> str:
# The name becomes part of the template source, so {{ ... }} in it is evaluated.
return app_env.from_string("Hello " + name + ", order {{ order_id }} has shipped.").render(
order_id=order_id
)
def shipped(name: str, order_id: str) -> str:
# The template is constant; the name is data.
return SHIPPED.render(name=name, order_id=order_id)
# Templates that customers write, such as an email footer: a separate sandboxed environment.
user_env = ImmutableSandboxedEnvironment(autoescape=True)
MAX_TEMPLATE = 10_000
def render_customer_template(source: str, *, shop_name: str, order_id: str) -> str:
if len(source) > MAX_TEMPLATE:
raise ValueError("template too long")
# Pass plain strings only: no objects with methods, no config, no request.
return user_env.from_string(source).render(shop_name=shop_name, order_id=order_id)If no user ever writes template text, the right setting for every engine is the same: load templates from your codebase and pass data. The table is for the features where users do author templates, such as email footers, invoice layouts or CMS pages. Treat that as running their code, and use the engine's own restriction mechanism together with the isolation in the sandbox section.
| Engine | What template text can reach | For user-authored templates |
|---|---|---|
| Jinja2 (Python) | Python objects through attributes such as __class__ and __globals__ | ImmutableSandboxedEnvironment with autoescape, Jinja2 3.1.6 or later, plain data only; the documentation says the sandbox alone is not perfect security. |
| Twig (PHP) | Any public method or property of objects in the context; Twig treats templates as trusted code | Twig\Sandbox\Sandbox (3.29 and later) with a strict SecurityPolicy and a dedicated Environment. |
| FreeMarker (Java) | ?new creates objects; ?api exposes Java APIs when enabled; wrapped objects expose their methods | allows_nothing class resolver (the FAQ says SAFER_RESOLVER is not restrictive enough), ?api off, a WhitelistMemberAccessPolicy. |
| Velocity (Java) | Public methods of context objects, reflection and class loading through the default uberspector | SecureUberspector, which prevents creation of arbitrary objects and reflection. |
| ERB (Ruby) | <% %> and <%= %> run arbitrary Ruby | Never compile user text with ERB; use a logic-less engine. |
| Handlebars (JavaScript) | Helpers you register; prototype members only if you enable them | Leave the 4.6.0 prototype restrictions on, register only pure helpers, stay on 4.7.7 or later (CVE-2021-23369). |
| EJS, Pug and similar (JavaScript) | Arbitrary JavaScript in <% %> or code blocks | Never compile user text; use Mustache or another logic-less engine. |
Twig's documentation puts it plainly: Twig treats templates as trusted code, and the sandbox is the only security boundary for templates written by untrusted authors. Twig 3.29 added the Twig\Sandbox\Sandbox class, which requires a dedicated Environment and a strict SecurityPolicy that allowlists tags, filters, functions, tests, methods and properties. In the test, the unsandboxed environment reads order.secretNote through its getter, while the sandbox renders order.id|upper and rejects both the other getter and a filter outside the policy. It ran with Twig 3.30.0 on PHP 8.5.11 in composer:2.10.3 with --network=none.
// Vulnerable: the shop owner's footer is compiled by the application's own environment.
function footer_vulnerable(Environment $app, string $source, Order $order): string
{
return $app->createTemplate($source)->render(['order' => $order]);
}
// Fixed: a dedicated environment wrapped in a strict sandbox policy (Twig 3.29+).
function footer_sandbox(string $source, Order $order): string
{
$env = new Environment(new ArrayLoader([]), ['autoescape' => 'html']);
$policy = new SecurityPolicy(
allowedTags: ['if'],
allowedFilters: ['escape', 'upper'],
allowedMethods: [Order::class => ['getId']],
);
$policy->setStrict(true);
return (new Sandbox($env, $policy))->createTemplate($source)->render(['order' => $order]);
}ERB has no sandbox mode: everything between <% and %> is Ruby. Interpolating input into the template string before ERB.new therefore runs it: in our minitest run on Ruby 4.0.7 (erb 6.0.7), <%= Process.pid %> in the input printed the process ID. Keep the template constant, as in ERB.new("Notice: <%= ERB::Util.html_escape(text) %>"), and pass values with result_with_hash, escaping them for the output context.
A sandbox inside the same process has to block every path from the objects it hands the template to the rest of the runtime, and dynamic languages have many such paths. The advisory record shows the result. Each fix below was needed even though the sandbox was configured correctly, so an application that renders user templates must track its engine's advisories and patch within days.
Design for the escape. Give the sandbox the data it needs as plain values, not service objects, ORM entities or the request; Jinja's own security considerations say the same and add that a small template can produce huge output, so set CPU and memory limits. Run user-authored rendering in a separate worker with no database credentials, no cloud credentials and no network, and cap template size and render time. When the content is a product feature, such as notebooks or plug-ins, use process, container or VM isolation; /domains/ai-emerging/llm-security/untrusted-model-output/ applies the same thinking to code a model generates.
| Sandbox | Advisory | Fixed in | What it allowed |
|---|---|---|---|
| Jinja2 SandboxedEnvironment | CVE-2024-56326 | 3.1.5 (21 December 2024) | Arbitrary Python through an indirect reference to str.format, for anyone who controls template content. |
| Jinja2 SandboxedEnvironment | CVE-2025-27516 | 3.1.6 (5 March 2025) | The attr filter could select the format method and bypass the sandbox's checks. |
| Twig sandbox | CVE-2026-46639, CVE-2026-24425 and others | 3.26.0 (20 May 2026) | Property and method restrictions bypassed through destructuring, and through a source policy. |
| Twig sandbox | CVE-2026-46636 and others | 3.27.0 (27 May 2026) | Allowlists bypassed when sandbox state changed between renders. |
| vm2 | 43 critical advisories | 3.10.x to 3.12.2 (January to September 2026) | Sandbox escapes to host code execution, among others. |
| Handlebars | CVE-2019-19919; CVE-2021-23369 | 4.3.0; 4.7.7 | Code execution through prototype access, and through some compile options with untrusted templates. |
Most requests for "custom templates" or "rules" are satisfied by something much smaller than a programming language. Let users choose among templates you wrote, and edit only the variables. Offer placeholders such as {order_id} and substitute them with a string replace that never evaluates anything. Map filter and sort choices from a request to code through an allowlist, as the Java example does.
When users need real expressions, use a language designed to be embedded with bounded cost. CEL, the Common Expression Language, describes itself as evaluating in linear time, mutation free and not Turing-complete, and lets the host provide the variables and functions. A hand-written parser for arithmetic over allowlisted fields, like the JavaScript and Python examples, covers spreadsheet-style formulas. For templates, a logic-less engine such as Mustache substitutes data and runs no code; keep autoescaping on and give it only the data it needs.
For every place that renders or evaluates something users influence, add a test that sends the probes for your engine, such as {{ 7 * 7 }} for Jinja2 and Twig, ${7*7} for FreeMarker and EL, #{7*7} for SpEL templates and <%= 7 * 7 %> for ERB and EJS, and asserts that the output contains the probe as text and not 49. Keep a vulnerable variant so the test can fail, as the suite below does. Avoid destructive payloads in tests; a harmless one proves evaluation just as well. The listing shows the template tests; the full file also covers the formula evaluator, and python -m unittest -v test_injection passes all eleven tests with Jinja2 3.1.6.
Static analysis finds most sinks. CodeQL has js/code-injection, py/code-injection, py/template-injection, rb/code-injection, cs/code-injection, java/spel-expression-injection, java/ognl-injection and java/server-side-template-injection. The tested fix cards at /fix/template-injection/ cover Express, Flask, Spring Boot and ASP.NET Core; per-stack fixes for eval-style code injection are at /fix/code-injection/. OWASP WSTG-INPV-11 and WSTG-INPV-18 describe how testers probe for these weaknesses.
import os
import unittest
from jinja2.exceptions import SecurityError
import calc
import templates
class TemplateTest(unittest.TestCase):
def test_vulnerable_evaluates_input(self):
self.assertIn("Hello 49,", templates.shipped_vulnerable("{{ 7 * 7 }}", "A1"))
def test_vulnerable_reaches_python_internals(self):
out = templates.shipped_vulnerable("{{ ''.__class__.__mro__ }}", "A1")
self.assertIn("class 'object'", out) # the tuple of str's base classes, escaped
def test_fixed_renders_input_as_text(self):
self.assertEqual(
templates.shipped("{{ 7 * 7 }}", "A1"), "Hello {{ 7 * 7 }}, order A1 has shipped."
)
def test_fixed_escapes_markup(self):
self.assertIn("<b>", templates.shipped("<b>", "A1"))
def test_sandbox_allows_data(self):
out = templates.render_customer_template(
"Thanks for shopping at {{ shop_name }} ({{ order_id }})", shop_name="Acme", order_id="A1"
)
self.assertEqual(out, "Thanks for shopping at Acme (A1)")
def test_sandbox_blocks_internal_attributes(self):
with self.assertRaises(SecurityError):
templates.render_customer_template(
"{{ ''.__class__.__mro__ }}", shop_name="Acme", order_id="A1"
)
def test_sandbox_checks_attributes_inside_str_format(self):
out = templates.render_customer_template(
"{{ '{0.__class__}'.format(shop_name) }}", shop_name="Acme", order_id="A1"
)
self.assertNotIn("class", out)- Blocklisting
__class__,process,requireorT(: engines offer many equivalent paths, and payloads encode around the list. - Escaping HTML in the input before concatenating it into a template: HTML escaping does not touch
{{ }},${ }or#{ }. - Rendering twice: the output of the first render, which contains user text, is fed to the engine again as template source.
- Passing the request, the configuration object or ORM entities into a sandboxed template "for convenience": each method is a path out.
- Error pages that echo the template source or the expression, which turns a failed probe into information disclosure.
- Treating an admin-only template editor as safe: an admin session hijacked through XSS or CSRF becomes code execution on the server.
- Allowing users to choose a template file name, which is path traversal into templates you never meant to expose (/domains/appsec/secure-coding/file-inclusion/).
Guided practice
A SaaS product added five customization features. Decide for each whether a customer can run code on the server, name the weakness (CWE-94, CWE-95, CWE-917 or CWE-1336), and give the fix.
Supplied scenario material
E1 Flask, email preview. subject comes from a form.
return render_template_string(f"<h1>Preview</h1><p>{subject}</p>")
E2 Spring, product list sorting. sort comes from ?sort=
Expression e = parser.parseExpression(sort);
items.sort(Comparator.comparing(i -> (Comparable) e.getValue(new StandardEvaluationContext(i))));
E3 Express, computed columns. formula comes from the column editor.
const fn = new Function('row', 'return ' + req.body.formula);
E4 Java, invoice layouts. Shop owners upload .ftl files; FreeMarker 2.3.35 with
new Configuration(Configuration.VERSION_2_3_34) and no other security settings.
E5 Node.js, customer email templates with Handlebars 4.7.10.
const t = Handlebars.compile(customerTemplate);
t(data, { allowProtoMethodsByDefault: true });1. E1: string formatting into a template
Predict the preview for
subject={{ 7 * 7 }}and write the fix.Expected result and reasoning
It shows 49: the f-string puts the subject into template source (CWE-1336). Use
render_template_string("<h1>Preview</h1><p>{{ subject }}</p>", subject=subject), or better a template file.2. E2: SpEL from a query parameter
Decide what
sort=T(java.lang.System).getenv()does and how to fix it.Expected result and reasoning
It returns the environment, and other type references run code (CWE-917). Map
sortto aComparatorthrough an allowlist; no expression language for request input.3. E3: new Function
Decide whether
formula=process.envis a problem and what to use instead.Expected result and reasoning
Yes (CWE-95): the function body is JavaScript with access to
process. Replace it with a parser for numbers, allowlisted field names and arithmetic, likeformula.js.4. E4: FreeMarker uploads
Name the settings that matter and their values in this configuration.
Expected result and reasoning
In 2.3.x the
newbuilt-in resolver is unrestricted by default, so a template can instantiate classes that run commands (CWE-1336). Setnew_builtin_class_resolvertoallows_nothing, keep?apioff, use aWhitelistMemberAccessPolicyand an isolated worker, or a logic-less engine.5. E5: Handlebars with prototype methods enabled
Explain the risk of the runtime option and what to change.
Expected result and reasoning
The documentation warns that prototype access may let template authors run code. Remove the option, register only pure helpers, and render in a worker with no secrets.
Paper exercise. Reproduce E1, E2 and E3 with the guide's code and harmless probes only, against a local copy.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Server-Side Template Injection Prevention Cheat Sheet
- OWASP WSTG: Testing for Server-side Template Injection (WSTG-INPV-18). Version 4.2.
- OWASP ASVS 5.0.0: V1 Encoding and Sanitization (V1.3.2, V1.3.7). Version 5.0.0.
- Node.js documentation: VM (executing JavaScript). Node.js v26.11.1.
- vm2 repository: README and security advisories. vm2 3.12.2.
- Python documentation: Built-in functions, eval and exec. Python 3 documentation.
- Python documentation: ast.literal_eval. Python 3 documentation.
- Spring Framework reference: SpEL evaluation and EvaluationContext. Spring Framework 7.0.9.
- Jinja documentation: Sandbox and security considerations. Jinja 3.1.x.
- Twig documentation: Sandbox. Twig 3.x.
- Apache FreeMarker FAQ: Can I allow users to upload templates? FreeMarker 2.3.35.
- Apache Velocity Engine developer guide: SecureUberspector. Velocity 2.4.1.
- Handlebars documentation: runtime options (prototype access). Handlebars 4.x.
Reference information
Checked against Python 3.14.8 with Jinja2 3.1.6, Node.js 26.11.1, OpenJDK 21.0.12 with spring-expression 7.0.9, PHP 8.5.11 with Twig 3.30.0, Ruby 4.0.7 with erb 6.0.7, the FreeMarker 2.3.35 documentation, Velocity 2.4.1, Handlebars 4.7.10 and vm2 3.12.2.
- Tags
- Injection
- SDLC phase
- Development
- Role
- Developers
- ASVS 5.0.0 requirements
- v5.0.0-1.3.2 · v5.0.0-1.3.7
- CAPEC attack patterns
- OWASP WSTG v4.2 tests
- WSTG-INPV-11 · WSTG-INPV-18