Choose your stack

Stacks without a card

How it happens

  • The code builds the template text from input by concatenation or interpolation and hands it to render_template_string, ejs.render, SpEL parseExpression or a scripting API, so the input is parsed as a template or as code instead of being shown.
  • A full engine exposes a rich object graph: Jinja2 reaches config, request and the module globals behind cycler and lipsum; EJS runs arbitrary JavaScript; SpEL resolves T(...) type references; Roslyn compiles the whole base class library. One expression then reads an environment secret or executes a command.
  • Two different mistakes look alike. Treating a plain value (a name) as a template needs no engine at all, just data binding. Genuinely user-authored templates (a notification a user writes) do need an engine, but a powerful one without a sandbox turns the feature into remote code execution.
  • Hand-made fixes fail. Blocklisting {{ or __class__ or T( misses the config, process.env and reflection routes to the same data, and run-time string building (~, +, |attr, ['con'+'structor']) rebuilds any blocked name, so the denylist never sees it.

Fix principles

  • Never build template text or code from input. Keep the template constant and pass each value as data, so the engine treats it as a string.
  • Where users must author templates, render them with a mechanism that cannot reach code: a sandbox (Jinja2 SandboxedEnvironment), a logic-less engine (Mustache), a restricted evaluation context (SpEL SimpleEvaluationContext) or a secure template engine (Fluid). Never a general-purpose scripting or expression engine.
  • Put only the data the template needs in scope. Keep config, request, environment variables, type references and reflection out of the render context, so sorting by or reaching a hidden value is impossible.
  • Do not denylist template syntax or dangerous tokens; the blocked names have synonyms, and run-time string building defeats substring matching.
  • Keep engine and parser error text away from clients; it tells an attacker which gadget to try next.
  • Run the application with least privilege and secrets out of ambient scope, so a case you missed does less damage.

How the cards prove it

  • Run each card's tests in three modes: the fixed implementation must pass, and the vulnerable and the plausible-but-wrong implementations must fail. run-all.mjs does this for every card.
  • The regression tests send real payloads (a data-access read of a server secret and a code-execution gadget) and require that the secret marker never appears in the response or that the request is rejected. Functional tests render legitimate templates and data, including non-ASCII values.
  • Static analysis is uneven. Audit rules flag every template call, including the safe constant-template ones; taint rules are file-local or interfile and miss the request-to-render path across modules; and some engines (EJS, Roslyn scripting) have no dedicated rule. Each card's scannerRules lists what was checked and what it found.

References

  1. CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
  2. CWE-94: Improper Control of Generation of Code ('Code Injection')
  3. CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
  4. OWASP Top 10:2025 A05 Injection
  5. OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
  6. PortSwigger Web Security Academy: Server-side template injection
  7. CAPEC-242: Code Injection

Attack patterns:

Practice in the browser

Guides on this weakness

Guide

Keep untrusted values out of executable syntax

Choose a safe interface for each interpreter and bound the work performed by parsers and converters.

Guide

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.

Guide

Keep untrusted CI work outside the release boundary

Review workflow authority, persistent state and artifact handoffs before contributor-controlled code reaches privileged jobs.

Guide

Prevent DOM XSS, postMessage abuse and client-side injection

Trace attacker-controlled values from the URL, messages and storage to the browser APIs that parse HTML or run code, then close those sinks with safe APIs, a sanitizer, Trusted Types and strict message checks.

Guide

Harden GitHub Actions against workflow and supply-chain attacks

Close the paths that give untrusted pull-request code secrets or a write token: pass context through env, keep pull_request_target away from fork code, pin actions to commit SHAs, scope GITHUB_TOKEN and OIDC, and treat artifacts as data.

Guide

Prevent file inclusion: LFI, RFI, dynamic imports and template names

Stop request data from choosing which file, module or template your application loads: local and remote file inclusion in PHP, dynamic require() and import() in Node.js, importlib and template names in Python, Thymeleaf view names in Spring, render in Rails, and the allowlist fix that works in all of them.

Guide

Sandbox model-generated code and computer-use agents

Run the code an agent writes, and agents that drive a browser or desktop, inside a boundary built for hostile code: a disposable runtime with no secrets and no default network, hard resource limits, and a person in front of every side effect.

Case study

Case study: ingress-nginx CVE-2025-1974 and the exposed admission webhook (2025)

How an admission webhook open to the whole Pod network made ingress-nginx's configuration-injection bugs a 9.8 cluster-takeover risk in 2025, and what its 2026 retirement means.

Case study

Case study: Spring4Shell (CVE-2022-22965)

How Spring's data binding let request parameters reach the class loader on JDK 9 and later in 2022, why the denylist workaround leaked, and what it teaches about framework-level mass assignment.