Security Engineering · Web Application Security
Server-Side Template Injection: When Data Reaches an Evaluator
Why template engines are interpreters, how a template chosen from user input escalates from text output to code execution, and why sandboxes are the wrong layer.
A template engine takes source text and produces output. Between those two points it parses the source into a syntax tree and evaluates that tree against a data model — resolving names, calling functions, indexing containers, iterating. That is an interpreter, and the only reason it is not treated as one is that the source text is normally supplied by the developer and therefore trusted.
When the source text is influenced by a user, the interpreter’s input is attacker-controlled. Whether that is a user-chosen template file, an email body rendered as a template, an error message formatted with user template syntax, or a parameter that is concatenated into a template before rendering, the result is that the attacker’s text is parsed rather than interpolated. The escalation from there depends entirely on what the evaluation context exposes.
The distinction that gets lost
Interpolation inserts a value into fixed text. Hello, {{ name }} renders the fixed text with the variable substituted. Injecting into the value of name produces text; the template structure is unaffected.
Template injection inserts into the source. Hello, + name where name is {{ 7*7 }} produces a template whose source contains an expression, and the engine evaluates it. The dollar-sign and brace-delimited syntax makes this look like string formatting, which is why the two are confused. The diagnostic that separates them is arithmetic: an input of ${7*7} or {{7*7}} that renders as 49 proves evaluation, and an input that renders literally proves interpolation. The probe is worth knowing because it distinguishes a templating bug from an escaping bug before any payload is constructed.
What the context exposes
A template engine’s expression language is small by design. It has literals, name lookup, attribute access, subscripting, function calls, filters, and the usual operators. The power of an injection is bounded by what those operations can reach from the variable names the template can see.
In a rich context, name lookup reaches the framework’s objects. Python’s Jinja2 resolves an attribute chain through whatever object it lands on, and built-in string and list methods are reachable from any string in the context. The class hierarchy is reachable from any object via its type, and through the type’s method resolution the module tree can be walked to a frame object, and from a frame to the interpreter’s builtins. Once builtins such as the import mechanism are reachable, arbitrary code execution follows without any single feature being broken: each step — attribute access, class lookup, frame access — is a documented part of the language. Engines that permit calling object methods expose the same chain in their own idiom, and engines that permit a subscript on a mapping expose it if the map contains a module or a callable.
Engines that restrict the expression language to property access on a declared data model are bounded by that model. A template that can read user.name and iterate orders cannot reach the filesystem unless the model contains something that can. This is the design that scales: the safety comes from the shape of the context, not from the features of the language.
Sandboxing, and why it is a second choice
Engines that expose a general-purpose expression language offer sandboxed modes. Jinja2’s sandbox is an environment subclass that changes the behaviour of attribute access and of certain calls, intervening at the points where the interpreter would reach outside the declared data. Twig’s sandbox extension restricts tags, filters, and object methods to an allowlist evaluated per template. Both are implementations of a policy over the same interpreter, and both have to be correct for every construct the interpreter supports.
The record of sandbox escapes in template engines is a record of features the sandbox did not anticipate. A filter that constructs a new object, an operator that invokes a dunder method, a built-in that returns a raw iterator over module state, an error formatting path that re-enters the evaluator — each is a place where the interpreter’s power exceeded the sandbox’s model of it. The direction of the fix is always to narrow the interpreter, and the direction of the discovery is always that something narrow enough to be usable is still wide enough to escape.
This is the same shape as the SQL injection analysis: the security property is that the untrusted text is never parsed as code, and any design in which it is parsed as code and then restricted has to enforce the restriction at every construct. For SQL, parameter binding avoids the problem. For templates, the analogous option is that user input is never part of the template source.
The surface that is easy to miss
Templates are not only in HTML. Mail bodies, PDF renderers, PR descriptions rendered by a web interface, invoice templates, and notification templates rendered server-side are all template sources. The pattern to look for is an application feature that lets a user customise output, because customisable output is templating.
Nested engines compound the problem. A template engine that renders into another language — a template that generates YAML, JSON, shell script, or an SQL statement — passes its output to a second parser. Sugar in the first language can produce syntax in the second. A template whose output is placed into a shell command can produce command syntax even if the template language itself has no shell access, because the shell is the second evaluator. The CRLF analysis covers the case where that second parser is an HTTP header block.
Client-side templates deserve a mention because the evaluation happens in the browser. A page that reads a template string from the URL and compiles it with a client-side library has the same defect, and the impact is bounded by the browser’s privilege rather than the server’s — which means it is a cross-site scripting primitive rather than a server compromise, and it sits outside the reach of any server-side control, in the same way that a script reading from the URL lies outside the output encoder’s reach. The source-and-sink framing applies directly, with template compilation as the sink.
Format strings are evaluators too
The same ordering appears in languages where formatting itself interprets input. Python’s str.format resolves attribute and item access on its arguments, so a format string supplied by a user can walk an object’s attributes; the format_map variant accepts a mapping and looks up keys named in the format string. C’s printf family interprets conversion specifiers, and passing user input as the format argument lets it read stack values through %x and write through %n. Go’s fmt package is not vulnerable in the same way — it reports a missing operand rather than reading one — but Go’s template package interprets its input as Go template syntax, with the same consequence as any other engine.
The common shape is a function that takes a format template and a set of arguments, and the rule is that the template is code and the arguments are data. Applications that swap them have produced the injection regardless of which function is involved. In dynamic languages, avoidable use of a general formatting function on attacker-influenced templates is worth removing even where the immediate exploit chain is not obvious.
Detection is a parsing question
Server-side detection of an injection is unreliable because the payload is consumed by the interpreter before the response is generated. A reflected {{7*7}} that comes back as 49 is caught only if the response differs from the request, and a payload that produces a blank or an error may not be visible at all. Engines in debug mode render exception traces containing the source, the parse tree, and the failing name, which is a diagnostic aid for the developer and an information leak for the attacker.
Testing correctly requires the arithmetic probe and careful comparison of the reflected output against the input, plus comparison against a control string that contains no template syntax. Engines whose delimiters are ordinary HTML fragments will not respond to the same probe, so the probe set has to be chosen from the engine in use — which is why identification of the engine precedes any exploitation attempt, and why the security review of an application should record which engine renders which surface.
The design that removes the class
Application-controlled templates with user data passed as context is the design that has no injection, because the parse happens on text the developer wrote. Where per-user customisation is a genuine product requirement, the options that keep the property are a fixed set of templates the user selects between, or a restricted syntax with no function calls and no attribute traversal beyond the declared model, implemented as a parser rather than as an allowlist over a general one.
The reason to prefer the parser is that its grammar is the security boundary. A grammar with no call production, no attribute traversal, and no subscript has no expression tree that can express reachability, and a bug in it produces a syntax error rather than an execution path. The sandbox over a general interpreter has the opposite property: a bug in it produces an execution path, and the interpreter behind it is unchanged.
References
- Server-Side Template Injection
- Jinja Documentation — Sandbox — Pallets Projects
- Twig Documentation — Sandbox Extension — Symfony
- The Java Virtual Machine Specification — Class Loading — Oracle