Don't compile input as a template: ejs.render(userTemplate) runs any <%= ... %> as JavaScript. Pass values to a fixed template, and for user-authored templates use a logic-less engine such as Mustache that only substitutes data. Do not denylist process or require; globalThis and process.env reach the secret with neither, and ['con'+'structor'] rebuilds the rest.
- Find every ejs.render, ejs.compile, Function or eval whose template or code is built from req.query, req.params or req.body, including in modules the routes call.
- When the input is a value, render a constant template and pass the value as data (or build the string directly with proper escaping).
- When users genuinely author templates, switch to a logic-less engine such as Mustache that cannot evaluate expressions, and pass only the data the template needs.
- Add regression tests with this card's payloads (a process.env read, a constructor-chain, a globalThis read) next to functional tests for legitimate templates.
- Return a generic 400 and log the engine error server-side; never echo it to the client.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function renderNotification(template, context) {
// EJS compiles the whole user template to a function and runs it, so an
// expression such as <%= process.env.SECRET %> executes server-side.
return ejs.render(template, context);
}With IMPL=vulnerable, 5 of 9 tests fail (5 exploit tests).
The fix: fixed template and data
export function greeting(name) {
// Constant template; the name is data and is HTML-escaped, so "<%= ... %>" or
// "{{ ... }}" in the value is printed literally, never executed.
return Mustache.render('<p>Hello {{name}}!</p>', { name });
}With IMPL=fixed, 9 of 9 tests pass.
The fix: logic-less engine for user-authored templates
export function renderNotification(template, context) {
// Logic-less: Mustache has no expression evaluation, loops with bodies only, and
// no property-chain calls, so a user-authored template cannot reach code or env.
return Mustache.render(template, context);
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
export function renderNotification(template, context) {
rejectBlocked(template); // same denylist; see BLOCKED above
return ejs.render(template, context);
}With IMPL=wrong, 3 of 9 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
test('exploit: greeting with a direct process.env read does not reveal the secret', async () => {
const res = await getGreeting('<%= process.env.SSTI_SECRET %>');
assert.ok(!res.text.includes(marker), `secret leaked: ${res.text}`);
});
test('exploit: greeting with an eval() payload does not reveal the secret', async () => {
const res = await getGreeting("<%= eval('process.env.SSTI_SECRET') %>");
assert.ok(!res.text.includes(marker), `secret leaked: ${res.text}`);
});
test('exploit: a rendered template reading process.env does not reveal the secret', async () => {
const res = await postRender('<%= process.env.SSTI_SECRET %>');
assert.ok(!res.text.includes(marker), `secret leaked: ${res.text}`);
});
test('exploit: a constructor-chain payload does not reveal the secret', async () => {
const res = await postRender("<%= ''.constructor.constructor('return process.env.SSTI_SECRET')() %>");
assert.ok(!res.text.includes(marker), `secret leaked: ${res.text}`);
});
test('exploit: a globalThis payload does not reveal the secret', async () => {
const res = await postRender('<%= globalThis.process.env.SSTI_SECRET %>');
assert.ok(!res.text.includes(marker), `secret leaked: ${res.text}`);
});
});- EJS compiles <%= %>, <%- %> and <% %> to JavaScript and runs it, so ejs.render or ejs.compile of any attacker-influenced string is remote code execution. EJS has no sandbox mode.
- eval(input) and new Function(input) are the same bug without a template engine; EJS is effectively new Function under the hood.
- A logic-less engine (Mustache) only substitutes values and renders sections; it cannot evaluate expressions, so a user-authored template stays data. Handlebars is logic-less too but has had prototype-pollution escapes, so keep it current and avoid runtime-registered helpers built from input.
- Mustache HTML-escapes {{ }} by default and leaves {{{ }}} unescaped; keep the escaping for XSS safety, which is separate from SSTI.
- process.env, globalThis and ({}).constructor.constructor('return process')() all reach the runtime, so a denylist of process/require/eval/constructor is defeated by globalThis and by ['con'+'structor'] rebuilding the name.
- Pug and Handlebars also precompile templates to JavaScript; compile only templates you control and pass user input as data to them.
- Express res.render runs a view engine over files you ship; the danger is compiling a string built from request data, not rendering a named view.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs node-express does this for you).
- Static analysis is weak here: Semgrep CE has no EJS template-injection rule (the p/javascript pack found nothing on impl/), and CodeQL's js/code-injection was not executed, so the regression tests are the control.
- Against your own endpoint, post a template of <%= process.env.SECRET %> and a constructor-chain payload and confirm no secret comes back and the logic-less engine renders them as text.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.lang.security.audit.code-string-concat.code-string-concat | No findings, here or across the whole p/javascript pack on impl/. This rule matches eval or new Function on a concatenated string in source; EJS compiles the template to a function inside the library, so ejs.render(userTemplate) is never matched. Semgrep CE ships no rule for EJS template injection, so the regression tests are the only automated control for this card.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/code-injection | CodeQL's JavaScript library models EJS as a template engine (EjsStyleSyntax, package 'ejs', in semmle/javascript/frameworks/Templating.qll) and treats a template compiled from untrusted input as a code-injection sink, with req.query as a remote source, so this query is expected to flag ejs.render of a request value where source and sink are connected.Checked: github/codeql main; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| ejs | 6.0.1 |
| mustache | 4.2.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the template-injection/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c npm test --silent
# Expected: a test fails
IMPL=wrong SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs template-injection/node-express runs all three modes and checks the expected results.
Verified
- Toolchain
- node 26.11.1
- express 5.3.0
- ejs 6.0.1
- mustache 4.2.0
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 5 of 9 tests fail (5 exploit tests); wrong: 3 of 9 tests fail (3 exploit tests)
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
- EJS documentation (tags compile to JavaScript)
- mustache.js README (logic-less templates)
- Mustache manual (mustache(5))
- OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
- PortSwigger Web Security Academy: Server-side template injection
- OWASP Top 10:2025 A05 Injection
Attack patterns: