Server-side template injection (SSTI)¶
When is it relevant¶
A new or changed code path where user-controlled text becomes part of a template's source, not just its data. Examples:
- compiling templates from strings with EJS, Pug, Handlebars, Nunjucks or Lodash _.template;
- email or notification templates that users can edit;
- expression engines.
It also applies when request objects are passed whole as render options.
Attack¶
Template syntax in the input runs on the server. In EJS, for example, <%= process.mainModule.require('child_process').execSync('id') %> runs a shell command. That can expose secrets and environment variables or give full remote code execution. Passing req.query or req.body straight into res.render can also inject engine options.
Mitigation¶
- Load templates from files or constants, and pass user input only as data.
- If users must author templates, use a logic-less engine (Mustache, or Handlebars without custom helpers) and render them in a sandbox.
- Pass render functions only the fields they need, never whole request objects.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Pass input as data, never as template source | Almost always | EJS |
| Fixed template files chosen from an allowlist | The template itself must vary | Express API |
| Logic-less engine for user-authored templates | Users genuinely need to write templates | OWASP injection |
In Express, template injection is usually remote code execution, not just information disclosure: EJS, Pug and Handlebars compile to JavaScript.
Vulnerable — user input compiled as a template
router.post('/preview', requireAuth, (req, res) => {
const html = ejs.render(req.body.template, { user: req.user })
res.send(html)
})
Vulnerable — user input chooses the template
Vulnerable — user input reaches the options object
EJS has had remote code execution through options such as outputFunctionName
and filename (for example CVE-2022-29078), so the options argument must never
come from a request.
Mitigated — input is data
Mitigated — allowlist the template name
const PAGES = new Set(['about', 'pricing', 'contact'])
const page = PAGES.has(req.query.page) ? req.query.page : 'about'
res.render(page, { user: req.user })
Docs: res.render
Mitigated — logic-less engine for user templates
const output = Mustache.render(userTemplate, {
name: customer.name,
total: formatMoney(invoice.total),
})
Mustache has no expression evaluation, so a template can reference the values you pass and nothing else. Render it in a worker with a timeout if the templates are untrusted.
Docs: mustache.js
Bypasses to check
- Check then change: the template string is validated or escaped, then concatenated with another fragment before compiling.
- Any compile-from-string API, even when only part of the string is user input: a subject line, an email signature, a report header.
lodash.template,nunjucks.renderString,handlebars.compile,pug.compile,new Function,vm.runInNewContext.- Sandboxes that are not sandboxes:
vmin Node shares the process and is not a security boundary. - Escaping applied to the rendered output rather than to the template source — by then the code has already run.
- The template name reaching
res.renderfrom input, which combines template selection with path traversal into the views directory.
What to grep for
ejs.render(,ejs.compile(,renderString(,Handlebars.compile(,pug.compile(,_.template(res.render(with a variable as the first argumentnew Function(,eval(,vm.