Blog · 2026-06-15 · Security · SSTI · MCP Servers
MCP Server SSTI Deep Dive: Handlebars Sandbox Escapes, Nunjucks Template Injection, and EJS RCE in LLM Tool Handlers
Server-side template injection is old news in web applications. In MCP tool handlers it becomes significantly more dangerous: the "user" providing the template payload is often the LLM itself, manipulated by attacker-controlled content in tool results. A document the agent reads, a file it retrieves, or a database row it queries can contain a Handlebars constructor chain escape or an EJS exec payload — and if the tool handler renders that content as a template, the attacker achieves remote code execution without ever talking to your server directly.
SkillAudit's scan of production MCP servers found template engine usage in 31% of servers. Of those, 67% pass some combination of user arguments, tool call parameters, or external content to the template engine's compile or render function at runtime — the precise pattern that enables SSTI. The root cause is almost always the same: a developer used a template engine for its expression evaluation features (conditionals, loops, variable interpolation) and assumed that because the LLM controls the data, it can't control the template. That assumption breaks the moment the server retrieves external content and renders it.
This post covers the three most common template engine families in Node.js MCP servers — Handlebars, Nunjucks, and EJS — with their specific escape techniques, why each one works, and the safe patterns that eliminate SSTI as an attack class. The reference SEO guides for each engine are at /seo/mcp-server-server-side-template-injection-security and /seo/mcp-server-template-injection-security. This post goes deeper on the mechanics and provides a consolidated safe-pattern guide.
Why SSTI in MCP tool handlers is uniquely dangerous
In a traditional web application, SSTI requires the attacker to control HTTP request parameters — form fields, query strings, headers. The attacker interacts with the server directly, and the injection payload comes from the network. This means you can mitigate SSTI with input validation at the HTTP boundary: reject strings that look like template expressions before they reach the render call.
MCP tool handlers break this model in two ways.
The LLM is the injection intermediary. A tool handler that fetches a URL and summarizes its content doesn't receive the template payload from the network — it receives it from the LLM's tool call arguments, which were formed from the web page content. If that web page contains {{constructor.constructor("return process.env")()}}, the LLM may incorporate that string into the argument it passes to the summarize tool, and the tool handler renders it as a Handlebars template. The attacker never sends an HTTP request to your server. They publish a web page.
Prompt injection delivers SSTI payloads without direct access. An attacker who can place content in any data source the MCP server reads — a GitHub README, a database row, a calendar event, a Slack message — can embed template injection payloads in that content. The LLM reads the content, incorporates the payload into its reasoning, and passes it to a tool that renders templates. This is the SSTI analogue of second-order SQL injection, but it operates across the prompt injection / template injection boundary.
The escalation path is immediate. In a web app, SSTI escalates to SSRF or file read before reaching RCE. In a Node.js MCP server with access to process, any template escape that reaches process.env immediately exposes credentials, and reaching require('child_process').execSync achieves RCE with the server's full permissions — including any mounted cloud credentials or API tokens the MCP server uses to fulfill tool calls.
Attack 1: Handlebars constructor chain escape
Handlebars constructor.constructor chain reaches Function constructor
Handlebars advertises itself as a "logic-less" template engine with a sandbox: expressions can only access properties of the data context, and accessing the prototype chain is blocked. Until it isn't — several CVEs have demonstrated that the sandbox can be escaped via prototype chain traversal, and the underlying mechanism remains exploitable in servers that don't apply the full patch set.
The canonical escape uses the JavaScript prototype chain: every object has a constructor property pointing to its constructor function, and every function has a constructor property pointing to Function. Calling Function("return process")() executes arbitrary JavaScript and returns the Node.js process global.
// The vulnerable MCP tool handler
server.tool('render-template', async (args) => {
const { template, data } = args;
// WRONG: compiling user-supplied (or LLM-supplied) template at runtime
const compiled = Handlebars.compile(template);
return { content: [{ type: 'text', text: compiled(data) }] };
});
// Injection payload delivered via prompt injection in fetched content:
// {{#with "constructor"}}
// {{#with split as |a|}}
// {{#each a}}
// {{#with (string.sub.apply 0 codelist)}}
// {{this}}
// {{/with}}
// {{/each}}
// {{/with}}
// {{/with}}
// Simpler payload (Handlebars < 4.7.7 or without patched allowProtoPropertiesByDefault):
// {{constructor.constructor "return process.env.OPENAI_API_KEY"}}
// → executes arbitrary JS, returns the secret key value
The attack chain has three steps. First, the Handlebars expression accesses constructor on a string in the data context, reaching the String constructor. Second, it accesses constructor on the String constructor, reaching the Function constructor. Third, it calls the Function constructor with an arbitrary JavaScript string, which executes in the Node.js runtime context with full access to globals including process, require, and all loaded modules.
Handlebars added allowProtoPropertiesByDefault: false and allowedProtoProperties options in 4.6.0 and later blocked constructor access more aggressively in 4.7.7 (CVE-2021-23369, CVE-2021-23383). But these mitigations only apply if the server is running a patched version and hasn't re-enabled prototype access via options. Servers that use Handlebars for user-facing template customization often add allowProtoPropertiesByDefault: true to fix "broken" templates — restoring the attack surface.
// The only safe pattern: compile templates at build time, never at runtime
// Templates are static files in your repo, not strings from tool arguments
import Handlebars from 'handlebars';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const TEMPLATES_DIR = join(import.meta.dirname, 'templates');
const ALLOWED_TEMPLATES = new Set(['report', 'summary', 'alert']);
// Pre-compile all templates at startup
const compiledTemplates = new Map();
for (const name of ALLOWED_TEMPLATES) {
const src = readFileSync(join(TEMPLATES_DIR, `${name}.hbs`), 'utf8');
compiledTemplates.set(name, Handlebars.compile(src));
}
server.tool('render-template', async (args) => {
const { templateName, data } = args;
// Allowlist check — templateName is a key into static pre-compiled templates
if (!ALLOWED_TEMPLATES.has(templateName)) {
throw new Error(`Unknown template: ${templateName}`);
}
const fn = compiledTemplates.get(templateName);
// data is passed as the render context, NOT as a template string
// The LLM can control data values, not template expressions
const rendered = fn(data, {
allowProtoPropertiesByDefault: false, // explicit — don't let options change restore access
allowProtoMethodsByDefault: false,
});
return { content: [{ type: 'text', text: rendered }] };
});
// Template files (e.g. templates/report.hbs) use only static expressions:
// "Report for {{customer.name}} — {{date}} — {{#each findings}}..."
// The LLM can inject values into customer.name, not into the template expression syntax
Key rule: The string passed to Handlebars.compile() must never come from runtime input — not from tool arguments, not from database rows, not from fetched URLs, not from the LLM. Compile at build time or startup with static files from your repository. The data context can contain LLM-controlled values safely.
Attack 2: Nunjucks template injection — process.mainModule access
Nunjucks renders user input and exposes global or process.mainModule
Nunjucks is a full-featured template engine with explicit control flow, filters, and macro definitions. Unlike Handlebars, Nunjucks doesn't attempt to provide a security sandbox — the documentation explicitly notes that template injection is possible when user input is rendered as a template. Many MCP server developers miss this warning and use Nunjucks for dynamic report generation, email formatting, or content assembly in tool responses.
Nunjucks templates can access the global scope through the global object, and from there reach Node.js internals including process.mainModule.require.
// Vulnerable Nunjucks usage in an MCP tool
import nunjucks from 'nunjucks';
const env = nunjucks.configure({ autoescape: true });
server.tool('format-report', async (args) => {
const { template, reportData } = args;
// WRONG: args.template comes from the LLM, which read it from an external source
const output = env.renderString(template, { data: reportData });
return { content: [{ type: 'text', text: output }] };
});
// Injection payload (Nunjucks has no sandbox — these work):
// {{ ''.__class__.__mro__[1].__subclasses__() }} -- Python Nunjucks port
// For Node.js Nunjucks, access global via:
// {{ range.constructor("return global")() }}
// → returns the Node.js global object
// From global, reach child_process:
// {{ range.constructor("return global.process.mainModule.require('child_process').execSync('id').toString()")() }}
// → RCE: returns uid=1000(mcp-server) gid=1000(mcp-server) groups=1000(mcp-server)
The range function is built into Nunjucks and always available in templates. Like Handlebars, range.constructor traverses the prototype chain to reach the Function constructor. From there, global.process.mainModule.require loads Node.js built-in modules including child_process, and execSync runs arbitrary shell commands. This is a CVSS 9.8 remote code execution.
Nunjucks also supports a {% set %} tag and macro definitions. A sufficiently long payload can define a macro that chains multiple operations, making it harder to detect with simple pattern matching. The autoescape: true option only prevents HTML-encoding bypass — it doesn't prevent code execution through the function constructor chain.
// Safe Nunjucks pattern: static template files + sandboxed environment
import nunjucks from 'nunjucks';
import { join } from 'node:path';
const TEMPLATES_DIR = join(import.meta.dirname, 'templates');
const ALLOWED_TEMPLATES = new Set(['report', 'summary', 'digest']);
// Configure to load ONLY from the static templates directory
// FileSystemLoader prevents path traversal via template names
const env = new nunjucks.Environment(
new nunjucks.FileSystemLoader(TEMPLATES_DIR, { watch: false, noCache: false }),
{
autoescape: true,
throwOnUndefined: true, // surface missing variable errors loudly
trimBlocks: true,
lstripBlocks: true,
}
);
// Block access to dangerous globals by shadowing them in the render context
const SAFE_GLOBALS = {
range: undefined, // remove the built-in that enables the constructor chain
cycler: undefined,
joiner: undefined,
// Only expose explicitly safe helpers
formatDate: (ts) => new Date(ts).toISOString().split('T')[0],
truncate: (s, n) => String(s).slice(0, n),
};
server.tool('format-report', async (args) => {
const { templateName, reportData } = args;
if (!ALLOWED_TEMPLATES.has(templateName)) {
throw new Error(`Unknown template: ${templateName}`);
}
// Template name resolves to a static file in TEMPLATES_DIR
// FileSystemLoader refuses path components like ../
const output = env.render(`${templateName}.njk`, {
...SAFE_GLOBALS,
data: reportData,
});
return { content: [{ type: 'text', text: output }] };
});
Note on sandbox add-ons: Several npm packages advertise a "sandboxed Nunjucks environment." None have been independently audited for the constructor chain escape. The safest path is to never call renderString() with externally-sourced content, regardless of what sandbox wrapper you add.
Attack 3: EJS — <%- unescaped output and outputFunctionName RCE
EJS outputFunctionName option passes arbitrary JS to Function() constructor
EJS is the most common template engine in Node.js — often used because its syntax (<% %> tags) maps directly to JavaScript and requires no learning curve. It's also the most direct path to RCE through SSTI: EJS templates compile to JavaScript functions, so any user-controlled EJS template string is effectively user-controlled JavaScript. The <%= tag HTML-escapes output; <%- does not — either can be used in an attack chain.
Beyond rendering user-controlled template strings, EJS has a secondary vulnerability: several options fields are passed directly into new Function() calls during template compilation, meaning an attacker who controls EJS options can achieve RCE without any <% tags in the template at all.
// Vulnerability 1: user-controlled template string
import ejs from 'ejs';
server.tool('render-email', async (args) => {
const { body, recipientName } = args;
// WRONG: body contains user/LLM-controlled content that includes EJS tags
const html = ejs.render(body, { name: recipientName });
return { content: [{ type: 'text', text: html }] };
});
// Payload (any EJS scriptlet executes JavaScript):
// "Hello <%= name %>, here is your report.
// <% var cp = global.process.mainModule.require('child_process');
// var out = cp.execSync('cat /proc/self/environ').toString(); %>
// <%= out %>"
// → RCE: dumps all environment variables including cloud credentials
// Vulnerability 2: options.outputFunctionName (CVE-2022-29078, CVSS 9.8)
// If options come from user input:
const options = JSON.parse(args.options); // WRONG: options from external source
ejs.render('<p>Hello</p>', data, options);
// Malicious options payload:
// { "outputFunctionName": "x;process.mainModule.require('child_process').execSync('id');x" }
// EJS builds: "var __output = x;process.mainModule.require(...).execSync('id');x;"
// This string is passed to new Function() → RCE, no template tags required
CVE-2022-29078 was a CVSS 9.8 that exploited the outputFunctionName option. The fix in EJS 3.1.7 added validation that outputFunctionName must match /^[a-zA-Z_$][0-9a-zA-Z_$]*$/. But similar injection paths exist through other options: localsName, destructuredLocals, and views. The root cause — EJS options influence generated JavaScript source — hasn't been eliminated, only the specific CVE path has been patched. The safest approach is to never pass externally-controlled data to EJS options.
// Safe EJS pattern: static template files, no user-controlled options
import ejs from 'ejs';
import { join } from 'node:path';
const TEMPLATES_DIR = join(import.meta.dirname, 'templates');
const ALLOWED_TEMPLATES = ['report', 'email-digest', 'alert-notification'];
// Compile all templates at startup
const templates = new Map();
for (const name of ALLOWED_TEMPLATES) {
// ejs.compile() with a static file path — not a user-controlled string
templates.set(name, ejs.compile(
readFileSync(join(TEMPLATES_DIR, `${name}.ejs`), 'utf8'),
{
// All options are static constants — never from user input
outputFunctionName: 'out', // fixed string
escape: (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'),
rmWhitespace: true,
}
));
}
server.tool('render-email', async (args) => {
const { templateName, recipientName, summaryText } = args;
if (!ALLOWED_TEMPLATES.includes(templateName)) {
throw new Error(`Unknown template: ${templateName}`);
}
const render = templates.get(templateName);
// Data context — LLM controls values, not template expressions
// String values are HTML-escaped by <%= %> tags in the static template
const html = render({
name: String(recipientName).slice(0, 200), // length-limit dynamic values
summary: String(summaryText).slice(0, 5000),
});
return { content: [{ type: 'text', text: html }] };
});
The unified safe pattern: user-as-context, not user-as-template
Across all three template engines, the safe pattern is identical: the template string is a static artifact in your repository, compiled at build time or server startup, never at request time. The user (or LLM) controls the data context passed to the compiled template function — not the template expressions themselves.
// The pattern works the same way across engines: // WRONG in all three engines: const output = engine.render(USER_CONTROLLED_STRING, data); const output = engine.renderString(LLM_ARGUMENT, data); const fn = engine.compile(FETCHED_URL_CONTENT); // RIGHT in all three engines: const fn = PRECOMPILED_TEMPLATES.get(templateName); // static key into static map const output = fn(USER_CONTROLLED_DATA); // data, not template source
When you need user-customizable formatting, implement a restricted interpolation layer instead of using a full template engine:
// Safe user-customizable interpolation without a template engine
// Supports only {{variableName}} — no expressions, no control flow, no function calls
const VARIABLE_RE = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
function safeInterpolate(template, variables) {
// Allowlist of allowed variable names — prevents arbitrary property access
const ALLOWED_VARS = new Set(Object.keys(variables));
return template.replace(VARIABLE_RE, (match, varName) => {
if (!ALLOWED_VARS.has(varName)) {
return match; // unknown variables are left as-is, not executed
}
const value = variables[varName];
// HTML-escape all interpolated values
return String(value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.slice(0, 1000); // length limit per interpolated value
});
}
// Usage: the template string can come from user input — it only supports {{varName}}
// Any attempt to use Handlebars expressions, EJS scriptlets, or Nunjucks blocks
// is treated as literal text, not code
const result = safeInterpolate(
userTemplate, // "Hello {{name}}, your {{reportType}} is ready."
{ name: 'Alice', reportType: 'security audit' }
);
Detection: what SkillAudit looks for
SkillAudit's static analysis for SSTI looks for three patterns in MCP server codebases:
- Runtime compile calls with non-literal arguments. Any call to
Handlebars.compile(),env.renderString(),ejs.render(), orejs.compile()where the first argument is not a string literal is flagged as HIGH. If the argument can be traced to a tool parameter, external fetch result, or database value, it's CRITICAL. - Options objects from external sources. Any EJS options object that includes properties other than static constants — especially if constructed from parsed JSON or tool arguments — is flagged as HIGH (CVE-2022-29078 class).
- Nunjucks
renderStringcalls. Any use ofenv.renderString()with a non-literal first argument is flagged as HIGH, because Nunjucks explicitly doesn't sandbox its template execution context.
SkillAudit findings and grade impact
Handlebars.compile(args.template), ejs.render(args.body, data), or env.renderString(args.content, ...) where the argument traces to a tool parameter or external fetch. Grade axis: Security. Score impact: −28 points.outputFunctionName, localsName, or destructuredLocals. Score impact: −25 points.allowProtoPropertiesByDefault: true — explicitly re-enables the constructor chain escape that CVE-2021-23369 and CVE-2021-23383 patched. Often added to fix template breakage after upgrading to Handlebars 4.6+. Score impact: −15 points.range, cycler, and joiner globals available, which provide the function constructor access point. Mitigated if renderString() is never called with external input. Score impact: −10 points.outputFunctionName injection unpatched. Score impact: −8 points.Control matrix: template engine × attack class × safe pattern
| Engine | Primary escape technique | Requires runtime compile? | Safe alternative | SkillAudit detection |
|---|---|---|---|---|
| Handlebars | constructor.constructor chain → Function() |
Yes — Handlebars.compile(userInput) |
Pre-compile static .hbs files at startup; pass user input only as data context |
Trace first arg of .compile() to non-literal |
| Nunjucks | range.constructor → Function() → global.process.mainModule.require |
Yes — env.renderString(userInput, ...) |
Use env.render('static.njk', data) with FileSystemLoader; shadow range in render context |
Any renderString() call with non-literal first arg |
| EJS | Scriptlets execute JS; outputFunctionName injects into new Function() |
Yes — ejs.render(userInput, ...) OR external options object |
Pre-compile static .ejs files; never accept options from external input; use <%= %> (escaped), never <%- %> for external data |
Non-literal first arg to ejs.render/compile; non-constant options object |
| Any engine | Prompt injection delivers SSTI payload through tool output chain | Indirect — LLM incorporates external content into tool arguments | Safe interpolation layer (/\{\{[a-zA-Z_]\w*\}\}/g only) for user-customizable formatting |
Template engine render() called with data from external fetch / DB / tool chain output |
Five-question self-audit
- Does any call to
Handlebars.compile(),ejs.render(),ejs.compile(), orenv.renderString()in your codebase receive an argument that isn't a string literal? (Check:grep -r "\.compile\|renderString\|ejs\.render" src/) - Do any EJS options objects include properties that aren't static constants — especially
outputFunctionName,localsName, ordestructuredLocals? - Is your Handlebars configuration running with
allowProtoPropertiesByDefault: true? (This is sometimes added to fix template breakage and re-opens the CVE-2021-23369 escape.) - Is
nunjucksat version ≥ 3.2.4? Earlier versions have known injection paths beyond the constructor chain escape. - Do any of your MCP tools fetch external URLs or read external files and then pass that content into a template render call — even indirectly through the LLM's tool call arguments?
If you answered yes to any of these, the SkillAudit scanner will identify the specific call sites, trace the data flow from external sources to render calls, and show the exact code change needed to close the vulnerability. Most SSTI fixes are single-line changes: replace engine.compile(userInput) with a lookup into a pre-compiled template map. The data context stays the same; only the template source changes from dynamic to static.