Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix server-side template injection in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

On this page

Steps

  1. 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.
  2. When the input is a value, render a constant template and pass the value as data (or build the string directly with proper escaping).
  3. 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.
  4. 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.
  5. Return a generic 400 and log the engine error server-side; never echo it to the client.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.jsJavaScript
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

Fixedimpl/fixed.jsJavaScript
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

Fixedimpl/fixed.jsJavaScript
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

Wrong fiximpl/wrong.jsJavaScript
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 regression test

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.

Testtest/render.test.jsJavaScript
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}`);
  });
});

Pitfalls

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

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix server-side template injection in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.lang.security.audit.code-string-concat.code-string-concatNo 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)
CodeQLjs/code-injectionCodeQL'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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
ejs6.0.1
mustache4.2.0

Run the tests

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.

Shell
npm ci
Shell
# 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 --silent

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

Download the project ZIP, 24 KB

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

  1. CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
  2. EJS documentation (tags compile to JavaScript)
  3. mustache.js README (logic-less templates)
  4. Mustache manual (mustache(5))
  5. OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
  6. PortSwigger Web Security Academy: Server-side template injection
  7. OWASP Top 10:2025 A05 Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.