Choose your stack
Fix server-side template injection in Node.js and Express
Don't compile input as a template: ejs.render(userTemplate) runs any <%= ... %> as JavaScript.
Open the fix card Python · FlaskFix server-side template injection in Python and Flask
Pass input as data, never as template text: render_template_string('Hello {{ name }}', name=name), not render_template_string('Hello ' + name).
Open the fix card Java · Spring BootFix server-side template (SpEL) injection in Java and Spring Boot
Evaluate user templates with a restricted context: SimpleEvaluationContext.forReadOnlyDataBinding() allows only property access on your data, so T(...) type references, constructors and reflection fail.
Open the fix card C# · ASP.NET CoreFix server-side template injection in C# and ASP.NET Core
Don't run user input as code: CSharpScript.EvaluateAsync compiles the 'template' as C#.
Open the fix cardStacks without a card
- TypeScript and Next.js: no card yet. The nearest is Fix server-side template injection in Node.js and Express, in the same language.
- Python and FastAPI: no card yet. The nearest is Fix server-side template injection in Python and Flask, in the same language.
- Python and Django: no card yet. The nearest is Fix server-side template injection in Python and Flask, in the same language.
- Go and net/http: no card yet. The nearest is Fix server-side template injection in Node.js and Express.
- Ruby on Rails: a card is planned (coverage row APP-11).
- PHP and Laravel: a card is planned (coverage row APP-11).
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
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
- OWASP Top 10:2025 A05 Injection
- OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
- PortSwigger Web Security Academy: Server-side template injection
- CAPEC-242: Code Injection
Attack patterns:
Practice in the browser
- Actions injection lab: Flag GitHub Actions expression injection · Practitioner, JavaScript
- SSTI lab: Render a welcome banner without template injection · Practitioner, Python
Guides on this weakness
Keep untrusted values out of executable syntax
Choose a safe interface for each interpreter and bound the work performed by parsers and converters.
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.
Keep untrusted CI work outside the release boundary
Review workflow authority, persistent state and artifact handoffs before contributor-controlled code reaches privileged jobs.
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.
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.
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.
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: 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: 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.