---
key: "code-template-injection"
title: "Keep untrusted input out of eval, expression languages and templates"
url: "https://softwaresecurity.com/domains/appsec/secure-coding/code-template-injection/"
api: "https://softwaresecurity.com/api/v1/articles/code-template-injection.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-94","CWE-95","CWE-1336","CWE-917","A05:2025","V1.3.2","V1.3.7","CAPEC-242","WSTG-INPV-11","WSTG-INPV-18"]
tags: ["Injection"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Keep untrusted input out of eval, expression languages and templates

Keep code and templates constant, pass input as data, and treat in-process sandboxes as one layer, not a boundary.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-coding/code-template-injection/

## In short

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, and `render_template_string('Hello ' + x)` runs code.
- The Node.js documentation says `node:vm` is not a security mechanism, and the Python documentation says overriding `__builtins__` is not one either.
- `ast.literal_eval` does 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; use `json.loads`.
- Spring's reference says `StandardEvaluationContext` must never evaluate an expression from an untrusted source, and that `SimpleEvaluationContext` restricts 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.

## Recommended defaults

| 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 |

## Verify it

1. Search the code for `eval(`, `new Function`, `setTimeout("`, `exec(`, `compile(`, `render_template_string`, `from_string`, `Template(`, `createTemplate`, `ERB.new`, `StandardEvaluationContext`, `parseExpression`, `Ognl.` and `ELProcessor`, and confirm each takes only constants or developer-written text.
2. Run your SAST tool's code-injection and template-injection rules (the CodeQL queries named above) and resolve every result.
3. 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, not `49`.
4. 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.
5. Check the dependency manifest for vm2, and for any in-process sandbox library, and confirm it is current and not the only control.

## 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.

Audience: Developers who build formulas, custom templates, filters or rules into a product, and reviewers who triage code-injection and SSTI findings.

Before you start:

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

Time: About 55 minutes: 25 to read, 10 to run one example and 20 for the exercise

## Input that becomes source code

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 |

## JavaScript: no eval, no Function, and vm is not a sandbox

`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/).

```js
// 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;
}
```

## Python: eval, exec and the limits of literal_eval

`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.

```python
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)
```

## Java expression languages: SpEL, OGNL and Jakarta EL

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.

```java
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");
    };
  }
}
```

## Template injection: where the template text comes from

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.

```python
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)
```

## What each engine exposes, and its setting for user templates

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

## PHP: the Twig sandbox for templates users write

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`.

```php
// 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]);
}
```

## Ruby: ERB templates are Ruby code

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.

## In-process sandboxes are a layer, not a boundary

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

## Give users a language you control

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.

## Test that expressions come back as text

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.

```python
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 &#39;object&#39;", 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("&lt;b&gt;", 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)
```

## Pitfalls



- Blocklisting `__class__`, `process`, `require` or `T(`: 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 material

```text
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.

Worked answer: 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.

Worked answer: It returns the environment, and other type references run code (CWE-917). Map `sort` to a `Comparator` through an allowlist; no expression language for request input.

### 3. E3: new Function

Decide whether `formula=process.env` is a problem and what to use instead.

Worked answer: Yes (CWE-95): the function body is JavaScript with access to `process`. Replace it with a parser for numbers, allowlisted field names and arithmetic, like `formula.js`.

### 4. E4: FreeMarker uploads

Name the settings that matter and their values in this configuration.

Worked answer: In 2.3.x the `new` built-in resolver is unrestricted by default, so a template can instantiate classes that run commands (CWE-1336). Set `new_builtin_class_resolver` to `allows_nothing`, keep `?api` off, use a `WhitelistMemberAccessPolicy` and 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.

Worked answer: 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.

Deliverable: A table of E1 to E5 with the weakness, the one-line fix and the probe your regression test sends.

Scope: Paper exercise. Reproduce E1, E2 and E3 with the guide's code and harmless probes only, against a local copy.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP Server-Side Template Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Template_Injection_Prevention_Cheat_Sheet.html)
- [OWASP WSTG: Testing for Server-side Template Injection (WSTG-INPV-18)](https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/07-Input_Validation_Testing/18-Testing_for_Server-side_Template_Injection). Version 4.2.
- [OWASP ASVS 5.0.0: V1 Encoding and Sanitization (V1.3.2, V1.3.7)](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x10-V1-Encoding-and-Sanitization.md). Version 5.0.0.
- [Node.js documentation: VM (executing JavaScript)](https://nodejs.org/api/vm.html). Node.js v26.11.1.
- [vm2 repository: README and security advisories](https://github.com/patriksimek/vm2/security/advisories). vm2 3.12.2.
- [Python documentation: Built-in functions, eval and exec](https://docs.python.org/3/library/functions.html#eval). Python 3 documentation.
- [Python documentation: ast.literal_eval](https://docs.python.org/3/library/ast.html#ast.literal_eval). Python 3 documentation.
- [Spring Framework reference: SpEL evaluation and EvaluationContext](https://docs.spring.io/spring-framework/reference/core/expressions/evaluation.html). Spring Framework 7.0.9.
- [Jinja documentation: Sandbox and security considerations](https://jinja.palletsprojects.com/en/stable/sandbox/). Jinja 3.1.x.
- [Twig documentation: Sandbox](https://twig.symfony.com/doc/3.x/sandbox.html). Twig 3.x.
- [Apache FreeMarker FAQ: Can I allow users to upload templates?](https://freemarker.apache.org/docs/app_faq.html) FreeMarker 2.3.35.
- [Apache Velocity Engine developer guide: SecureUberspector](https://velocity.apache.org/engine/2.4.1/developer-guide.html). Velocity 2.4.1.
- [Handlebars documentation: runtime options (prototype access)](https://handlebarsjs.com/api-reference/runtime-options.html). Handlebars 4.x.