Claude skill template engine injection
Template engines are powerful precisely because they execute code — that power becomes a vulnerability when the template itself comes from user-controlled input. In Claude MCP skills, tool arguments are not trusted user input: they are adversarial input. The LLM constructs tool arguments from external content — emails, web pages, documents, API responses — that may contain injection payloads specifically crafted to reach the skill’s template engine. A Handlebars helper, a Nunjucks filter chain, an EJS code tag, or a Pug unescaped expression embedded in that external content can achieve full remote code execution inside the skill’s Node.js process. The four SA-SKL-SBX patterns describe how each major Node.js template engine exposes this path when the template string itself originates from LLM tool arguments rather than a fixed, developer-controlled template file.
MCP skills frequently use template engines to generate HTML emails, documents, notifications, or structured output from user-supplied data. The safe pattern is straightforward: load a fixed template file the developer controls, then pass user data as template context variables. The vulnerable pattern is equally common: accept a template string from tool arguments and pass it directly to the engine’s string-compilation or string-rendering function. The distinction matters because every major Node.js template engine evaluates the template string as executable code — that is how template engines work. Passing user-controlled content as the template string, rather than as template context, means the attacker controls the executable code, not merely its inputs. Every MCP skill that calls Handlebars.compile(args.template), env.renderString(args.template, ctx), ejs.render(args.template, ctx), or pug.render('p !{' + args.content + '}') is a remote code execution vulnerability waiting to be triggered by a crafted document the LLM summarises.
SA-SKL-SBX-001: Handlebars compile(userTemplate) executes registered helpers with arbitrary code access
Handlebars is widely used in MCP skills for generating structured output: email bodies, Markdown reports, Slack message blocks. When a skill accepts a template string from tool arguments and calls Handlebars.compile(userTemplate)(context), it grants the template author full access to every helper registered on the Handlebars instance. Registered helpers are ordinary JavaScript functions that execute in the Node.js process scope — they can read environment variables, access the filesystem, make network requests, and spawn child processes. An attacker-controlled template string does not need to define new helpers; it needs only to invoke existing ones in ways the developer did not anticipate. The {{#with process}}{{env.SECRET_KEY}}{{/with}} pattern accesses process.env through the Handlebars context lookup mechanism if process is reachable from the template context. Beyond context access, a malicious template can exploit Handlebars’ {{lookup}} and {{#each}} builtins to enumerate and exfiltrate context properties. The only safe fix is to never compile user-supplied strings: use Handlebars.precompile() on developer-controlled template sources at build time and load the compiled templates at runtime, or maintain a strict whitelist of named compiled templates and only render those.
// VULNERABLE: compiling a template string from LLM tool arguments
import Handlebars from 'handlebars';
import { createServer } from '@anthropic-ai/mcp-sdk/server';
// Skill registers helpers that have Node.js process access
Handlebars.registerHelper('env', (key: string) => process.env[key]);
Handlebars.registerHelper('exec', (cmd: string) => {
// Intended for safe internal use — exposed to any compiled template
return require('child_process').execSync(cmd).toString();
});
server.setToolHandler('render_template', async (args) => {
// args.template comes from LLM tool arguments — attacker-controlled
const template = Handlebars.compile(args.template);
// The compiled template has access to all registered helpers above
return { output: template(args.context ?? {}) };
});
// ATTACKER PAYLOAD (injected via a malicious document the LLM processes):
// args.template = "{{env 'SECRET_KEY'}}{{env 'DATABASE_URL'}}"
// → renders process.env.SECRET_KEY and process.env.DATABASE_URL
//
// args.template = "{{exec 'cat /etc/passwd'}}"
// → executes cat /etc/passwd and returns the output
//
// args.template = "{{#with this}}{{lookup . 'constructor'}}{{/with}}"
// → traverses prototype chain through context object
// Even without registered helpers, Handlebars context traversal is exploitable:
// If context contains a reference to process or global:
// args.template = "{{#with process}}{{env.SECRET_KEY}}{{/with}}"
// → if context.process === global.process, reads environment variables
// SECURE: precompile templates at build time, render with named templates only
import Handlebars from 'handlebars';
import fs from 'fs';
import path from 'path';
// Load only developer-controlled template files — no user-supplied strings
const TEMPLATE_DIR = path.resolve(__dirname, '../templates');
const compiledTemplates: Record<string, Handlebars.TemplateDelegate> = {};
// Build-time compilation from known safe files
for (const file of fs.readdirSync(TEMPLATE_DIR)) {
if (!file.endsWith('.hbs')) continue;
const name = file.replace('.hbs', '');
const src = fs.readFileSync(path.join(TEMPLATE_DIR, file), 'utf8');
compiledTemplates[name] = Handlebars.compile(src);
}
server.setToolHandler('render_template', async (args) => {
// Template name from args is validated against the fixed whitelist
const templateName = String(args.templateName ?? '');
if (!compiledTemplates[templateName]) {
throw new Error(`Unknown template: ${templateName}`);
}
// User data provided only as context — not as template code
// Context is validated against a schema before rendering
const safeContext = validateContext(args.context);
return { output: compiledTemplates[templateName](safeContext) };
});
// DETECTION (SkillAudit):
// Handlebars.compile(args.*) — user-supplied string passed to compile()
// OR Handlebars.compile(context.*) where context comes from tool call
const hbsCompileUser = searchAST(
'Handlebars.compile(args.*) OR Handlebars.compile(context.*) ' +
'OR hbs.compile(args.*)'
);
if (hbsCompileUser.length > 0) → CRITICAL // SA-SKL-SBX-001
Registered helpers are the attack surface, not just the template: Handlebars helpers are JavaScript functions registered globally on the Handlebars instance. Every helper is callable from every compiled template. A skill that registers a helper for legitimate internal use — reading config, calling an internal API, formatting dates with a utility function — exposes that helper to any user-compiled template. The principle of least privilege applies: helpers with privileged access should never be registered when user-supplied templates are in scope. The safe architecture is build-time precompilation of all templates from developer-controlled sources, with no compile() calls at runtime.
Handlebars.compile(args.template) executes user-supplied template string with full access to registered helpers and context prototype chain; enables environment variable exfiltration and RCE via registered helpers. Detection: Handlebars.compile(args.*) → CRITICAL.SA-SKL-SBX-002: Nunjucks renderString with user-supplied template enables filter chain attacks
Nunjucks is a full-featured template engine with a powerful filter and extension system. When a skill calls env.renderString(args.template, context) where args.template comes from LLM tool arguments, the attacker controls the template source and can use Nunjucks’ filter pipeline to traverse JavaScript object prototypes and access the Node.js runtime. The |attr filter accesses named attributes of an object, and | chains filters sequentially — together they enable prototype chain traversal that ultimately reaches global.process. The {% set proc = "test"|attr("constructor") %}{{ proc.call(global.process) }} pattern demonstrates how a string’s constructor can be accessed via |attr, then called with a privileged object as context. Beyond prototype traversal, Nunjucks’ {% set %} tag allows variable assignment, {% for %} allows iteration over any iterable, and the |safe filter disables HTML escaping — meaning output containing injected JavaScript will be passed to any downstream HTML renderer without sanitisation. Skills that call renderString() or render() with user-supplied template strings must be refactored to use fixed template files with user data passed as context only.
// VULNERABLE: Nunjucks renderString with user-controlled template
import nunjucks from 'nunjucks';
import { createServer } from '@anthropic-ai/mcp-sdk/server';
const env = nunjucks.configure({ autoescape: true });
// Custom filter registered for internal use — reachable from any renderString call
env.addFilter('readFile', (filepath: string) => {
return require('fs').readFileSync(filepath, 'utf8');
});
server.setToolHandler('generate_email', async (args) => {
// args.template comes from LLM tool call — attacker-controlled
// renderString compiles and renders the template string in the Nunjucks engine
const output = env.renderString(args.template, args.data ?? {});
return { html: output };
});
// ATTACKER PAYLOADS (injected via crafted email/document the LLM processes):
// 1. Access registered custom filter to read arbitrary files:
// args.template = "{{ '/etc/passwd' | readFile }}"
// → calls the readFile filter with attacker-supplied path
// 2. Prototype chain traversal via |attr filter:
// args.template = "{% set c = ''|attr('constructor') %}{{ c.call(global) }}"
// → accesses String.prototype.constructor via |attr, calls it with global as context
// 3. Enumerate context to exfiltrate data:
// args.template = "{% for k, v in data %}{{ k }}: {{ v }}{% endfor %}"
// → dumps the entire context object including any sensitive properties
// 4. Bypass autoescape with |safe to inject raw HTML/JS into output:
// args.template = "{{ data.name|safe }}"
// → if data.name contains <script>...</script>, passes it unsanitised downstream
// SECURE: use a fixed template file; pass user data as context only
import nunjucks from 'nunjucks';
import path from 'path';
// Configure to load templates from a fixed directory only
// addFilter and addGlobal are not called — no custom filters exposed
const safeEnv = nunjucks.configure(
path.resolve(__dirname, '../email-templates'),
{ autoescape: true, noCache: false }
);
const ALLOWED_TEMPLATES = new Set(['welcome', 'notification', 'report-summary']);
server.setToolHandler('generate_email', async (args) => {
const templateName = String(args.templateName ?? '');
// Validate template name against explicit allowlist — no path traversal
if (!ALLOWED_TEMPLATES.has(templateName)) {
throw new Error(`Template not found: ${templateName}`);
}
// User data passed as context — not as template source
const safeData = validateEmailData(args.data);
// render() loads from the fixed template directory — not renderString()
const output = safeEnv.render(`${templateName}.njk`, safeData);
return { html: output };
});
// DETECTION (SkillAudit):
// env.renderString(args.*) — user-supplied string as first argument
// OR nunjucks.renderString(args.*) at module level
// OR new nunjucks.Environment().renderString(args.*)
const njkRenderString = searchAST(
'env.renderString(args.*) OR nunjucks.renderString(args.*) ' +
'OR *.renderString(args.*) OR *.renderString(context.*)'
);
if (njkRenderString.length > 0) → CRITICAL // SA-SKL-SBX-002
Nunjucks filter registration is global and persistent: when a skill registers custom filters via env.addFilter(), those filters remain available for the lifetime of the Nunjucks environment. If the same environment instance is used for both trusted internal rendering and user-facing renderString() calls, every custom filter becomes reachable from attacker-supplied templates. The attack does not require the attacker to know which filters are registered — a Nunjucks template can probe for available filters by attempting calls and observing error messages or output differences. Use separate Nunjucks environment instances for internal rendering (with full filter access) and user-facing rendering (with no custom filters and fixed template files only).
env.renderString(args.template, ctx) enables filter chain prototype traversal and custom filter abuse; registered filters with filesystem or network access are callable from any attacker-supplied template. Detection: *.renderString(args.*) → CRITICAL.SA-SKL-SBX-003: EJS template string rendered with user content
EJS (Embedded JavaScript) is explicit about what its template tags do: <%= expr %> evaluates a JavaScript expression and outputs the HTML-escaped result; <%- expr %> evaluates a JavaScript expression and outputs the raw, unescaped result; and <% code %> executes arbitrary JavaScript without producing output. A skill that calls ejs.render(args.template, context) where args.template comes from LLM tool arguments is executing arbitrary JavaScript supplied by the attacker. The most direct payload is <%- process.mainModule.require('child_process').execSync('id') %>, which reaches the child_process module through process.mainModule.require and executes a shell command. Even the “safe” <%= escaped-output tag can be exploited: <%= constructor.constructor('return process')() %> accesses the Function constructor through the template scope’s object prototype, creates a new function that returns process, and calls it — yielding the Node.js process object with full environment access. The res.render() path in Express-based skills is equally dangerous when the template path comes from user arguments: a path traversal payload in args.view can cause Express to render an arbitrary file from the filesystem as an EJS template.
// VULNERABLE: ejs.render() with user-supplied template string
import ejs from 'ejs';
import express from 'express';
import { createServer } from '@anthropic-ai/mcp-sdk/server';
// Pattern 1: direct ejs.render() call with template from tool args
server.setToolHandler('render_report', async (args) => {
// args.template is a string from the LLM tool call — fully attacker-controlled
const output = ejs.render(args.template, {
user: args.user,
data: args.reportData
});
return { html: output };
});
// ATTACKER PAYLOADS:
// RCE via unescaped tag and child_process:
// args.template = "<%- process.mainModule.require('child_process').execSync('id') %>"
// → executes shell command, returns output in rendered HTML
// RCE via escaped tag exploiting Function constructor:
// args.template = "<%= constructor.constructor('return process.env')() %>"
// → returns process.env object via Function constructor chain
// Arbitrary code execution block:
// args.template = "<% const cp = process.mainModule.require('child_process'); %>
// <%- cp.execSync('cat /etc/passwd').toString() %>"
// → multi-tag attack: first tag acquires module, second executes and outputs
// Exfiltrate environment variables:
// args.template = "<%= JSON.stringify(process.env) %>"
// → dumps all environment variables including secrets
// Pattern 2: res.render() with template path from user args — path traversal
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.get('/mcp/render', (req, res) => {
// Template name from query/args — attacker can traverse outside views directory
const viewName = req.query.view as string;
// A crafted viewName like '../../config/database' could render a config file as EJS
res.render(viewName, { user: req.session?.user });
// ↑ if viewName = '../../.env', Express attempts to render .env as EJS template
// Any <%= ... %> patterns in the .env file execute as JavaScript
});
// SECURE: fixed template name from allowlist; user data as context only
import ejs from 'ejs';
import path from 'path';
import fs from 'fs';
const TEMPLATE_DIR = path.resolve(__dirname, '../views');
const ALLOWED_VIEWS = new Set(['report', 'summary', 'notification', 'welcome']);
server.setToolHandler('render_report', async (args) => {
const viewName = String(args.view ?? '');
// Validate against explicit allowlist — no path traversal possible
if (!ALLOWED_VIEWS.has(viewName)) {
throw new Error(`Unknown view: ${viewName}`);
}
// Load the pre-approved template file — path constructed from safe components only
const templatePath = path.join(TEMPLATE_DIR, `${viewName}.ejs`);
const templateSource = fs.readFileSync(templatePath, 'utf8');
// User data passed as context variables — cannot alter template code
const safeContext = validateReportData(args.reportData);
const output = ejs.render(templateSource, safeContext);
return { html: output };
});
// DETECTION (SkillAudit):
// ejs.render(args.*) — first arg is user-controlled string
// OR ejs.renderFile(args.*) — path from user args
// OR res.render(args.*) | res.render(req.query.*) — view path from request
const ejsUserTemplate = searchAST(
'ejs.render(args.*) OR ejs.render(context.*) ' +
'OR ejs.renderFile(args.*) ' +
'OR res.render(args.*) OR res.render(req.query.*) OR res.render(req.body.*)'
);
if (ejsUserTemplate.length > 0) → CRITICAL // SA-SKL-SBX-003
EJS’s <%= %> escape tag does not prevent code execution — it only escapes HTML output: the common assumption is that using the “safe” <%= %> tag instead of <%- %> prevents injection. This is wrong. Both tags evaluate a JavaScript expression; the only difference is whether the output is HTML-escaped. An expression like constructor.constructor('return process')() runs the same JavaScript regardless of which output tag wraps it. The only secure approach is to ensure the template source itself never comes from user input. EJS provides no sandbox or evaluation restriction — it is a direct JavaScript evaluation engine.
ejs.render(args.template, ctx) executes arbitrary JavaScript via <%- %> and <%= %> tags; even escaped-output tags reach process via Function constructor chain; res.render(args.view) enables path traversal RCE. Detection: ejs.render(args.*) or res.render(args.*) → CRITICAL.SA-SKL-SBX-004: Pug template injection via unescaped !{} expression
Pug (formerly Jade) uses two distinct interpolation syntaxes: #{ expr } evaluates a JavaScript expression and HTML-escapes the result, and !{ expr } evaluates a JavaScript expression and outputs the result raw and unescaped. When a skill concatenates user content into a Pug template string — for example, pug.render('p !{userContent}') where userContent comes from tool arguments — the !{} expression executes the user-supplied content as JavaScript in the Node.js process. The payload process.mainModule.require('child_process').execSync('cat /etc/passwd') placed inside a !{} expression achieves full RCE. The superficially safer #{} tag is also exploitable when the template itself is user-controlled: if the attacker provides the entire template string (not merely the interpolated value), they can use - (Pug’s unbuffered code line) to execute arbitrary JavaScript: - var x = require('child_process').execSync('id')\np= x. Even when only content within #{} is user-controlled, Pug does not sandbox the expression evaluation — it runs in the same Node.js scope as the rest of the template. The correct architecture is never to concatenate user content into a Pug template string; instead, pass user content as named template locals and reference them with #{variable} where the variable name is developer-controlled and the value is validated to be a safe string type before rendering.
// VULNERABLE: user content concatenated into Pug template string
import pug from 'pug';
import { createServer } from '@anthropic-ai/mcp-sdk/server';
// Pattern 1: unescaped !{} with user content directly injected
server.setToolHandler('render_card', async (args) => {
// args.content comes from LLM tool arguments — attacker-controlled
// !{} is Pug's UNESCAPED expression — evaluates args.content as JavaScript
const template = `div.card\n h2 Result\n p !{${args.content}}`;
return { html: pug.render(template) };
});
// ATTACKER PAYLOADS:
// RCE via unescaped !{} expression:
// args.content = "process.mainModule.require('child_process').execSync('id')"
// Template becomes: p !{process.mainModule.require('child_process').execSync('id')}
// → executes shell command and outputs the result
// File read via !{} expression:
// args.content = "require('fs').readFileSync('/etc/passwd','utf8')"
// → reads and outputs /etc/passwd content
// Environment variable exfiltration via !{}:
// args.content = "JSON.stringify(process.env)"
// → outputs all environment variables as JSON
// Pattern 2: user controls entire template string — unbuffered code execution
server.setToolHandler('render_template', async (args) => {
// args.template is the full Pug template — attacker controls everything
return { html: pug.render(args.template, args.locals ?? {}) };
});
// ATTACKER PAYLOAD for full template control (unbuffered code lines):
// args.template = "- var x = require('child_process').execSync('whoami').toString()\np= x"
// → Pug's '-' line executes unbuffered JavaScript; p= outputs the result
// Even #{} is exploitable when attacker controls the full template:
// args.template = "- var f = require\np #{f('child_process').execSync('ls /')}"
// → acquires require via unbuffered line, then uses #{} for output
// Pattern 3: user content interpolated with #{} but template is still user-controlled
// args.template = "p Hello #{name}" — if the entire template comes from user,
// they can replace it with: "- process.exit(1)" → crashes the Node.js process
// SECURE: fixed template structure; user data as named locals only
import pug from 'pug';
import path from 'path';
const TEMPLATE_DIR = path.resolve(__dirname, '../pug-templates');
const ALLOWED_TEMPLATES = new Set(['card', 'report', 'summary', 'notification']);
// Precompile all templates at startup — no runtime compilation of strings
const compiledFunctions: Record<string, pug.compileTemplate> = {};
for (const name of ALLOWED_TEMPLATES) {
const file = path.join(TEMPLATE_DIR, `${name}.pug`);
compiledFunctions[name] = pug.compileFile(file);
}
server.setToolHandler('render_card', async (args) => {
const templateName = String(args.templateName ?? '');
if (!compiledFunctions[templateName]) {
throw new Error(`Unknown template: ${templateName}`);
}
// Validate user content to be a plain string — no Pug syntax, no JavaScript
const content = String(args.content ?? '');
if (/[#{!<>]/.test(content)) {
throw new Error('Invalid characters in content');
}
// User data passed as named locals to the pre-compiled template
// Template uses #{content} — Pug HTML-escapes the local variable value
// Attacker cannot inject !{} or - code lines because template is fixed
return { html: compiledFunctions[templateName]({ content }) };
});
// DETECTION (SkillAudit):
// pug.render('...' + args.*) — string concatenation into template
// OR pug.render(args.*) — full template from user args
// OR pug.compile(args.*) — compiling user-supplied template string
// OR template literal containing !{${args.*}} or #{${args.*}} with user data
const pugUserTemplate = searchAST(
'pug.render(args.*) OR pug.compile(args.*) ' +
'OR pug.render(`*${args.*}*`) ' +
'OR template literal containing `!{${*}}` with user-controlled interpolation'
);
if (pugUserTemplate.length > 0) → CRITICAL // SA-SKL-SBX-004
The difference between #{} and !{} in Pug is HTML escaping, not JavaScript sandboxing: #{ expr } evaluates expr as JavaScript and HTML-escapes the string result. !{ expr } evaluates expr as JavaScript and outputs the result without HTML escaping. Both evaluate JavaScript. The security guarantee of #{} is only that the output is safe to embed in HTML — it says nothing about what the JavaScript expression can do during evaluation. If user content reaches a Pug expression interpolation site (whether #{} or !{}), it runs as JavaScript. The only safe usage is to pass user content as a local variable and reference the variable name — never the value — in the template source.
!{} executes as JavaScript; unbuffered - lines in user-supplied templates achieve RCE; even #{} is exploitable when the attacker controls the full template. Detection: pug.render(args.*) or string concatenation into Pug template → CRITICAL.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| SBX-001: Handlebars compile(userTemplate) | CRITICAL | Precompile templates from developer-controlled .hbs files at build time; load compiled templates by name from a fixed allowlist at runtime; never call Handlebars.compile() on user-supplied strings; register privileged helpers only on an isolated internal environment instance |
| SBX-002: Nunjucks renderString user template | CRITICAL | Replace env.renderString(args.template, ctx) with safeEnv.render(fixedTemplateName, ctx); configure a separate Nunjucks environment for user-facing rendering with no custom filters and a fixed template directory; validate template names against an explicit allowlist |
| SBX-003: EJS user-supplied template string | CRITICAL | Never pass user-supplied strings to ejs.render(); for res.render(), validate the view name against a fixed allowlist before calling; load template source from a developer-controlled directory using path.join(TEMPLATE_DIR, allowedName + '.ejs') |
| SBX-004: Pug !{} expression injection | CRITICAL | Precompile all Pug templates from .pug files using pug.compileFile() at startup; pass user content as named locals; validate locals to be plain strings with no Pug syntax characters; never concatenate user content into the template string argument |
SkillAudit detects all four SA-SKL-SBX-001 through SBX-004 template engine injection patterns through AST-based taint analysis that traces tool argument values through template compilation and rendering call sites. Related injection family: SA-SKL-INJ prompt injection attack surface covers how attacker-controlled content reaches MCP tool arguments from emails, documents, and web pages. See the complete 15-family SA-SKL attack surface reference for the full taxonomy.
Scan your Claude skill for template engine injection
SkillAudit detects Handlebars compile, Nunjucks renderString, EJS user template, and Pug expression injection patterns across the full SA-SKL-SBX family in 60 seconds.
Get your free audit