Topic: mcp server server-side template injection security

MCP server SSTI (server-side template injection) security — Handlebars sandbox escapes, Nunjucks, EJS RCE

Server-side template injection (SSTI) occurs when an MCP tool handler passes user-controlled content to a template engine for rendering rather than treating it as data. In Handlebars, this enables sandbox escapes via constructor chain access; in Nunjucks, template strings can call attributes to access the process object; in EJS, the <%- tag renders arbitrary JavaScript. SSTI in MCP servers is particularly dangerous because many tools generate dynamic content (reports, code, summaries) using template engines, and LLM-generated or user-supplied content flows directly into template variables.

1. Handlebars SSTI and sandbox escape

Handlebars applies a sandbox that should prevent access to the JavaScript object prototype chain. However, historical bypasses using lookup helpers, constructor property access, and prototype chain traversal have allowed attackers to escape the sandbox and execute arbitrary code. The fundamental problem is using user-supplied strings as the template rather than the context data:

import Handlebars from "handlebars";

// VULNERABLE: user input is used as the template string — SSTI
server.tool("render_report", {
  template: z.string(),   // User supplies the template — this is the vulnerability
  data: z.object({ name: z.string(), value: z.number() }),
}, async ({ template, data }) => {
  // Attacker passes template: "{{#with (lookup this.constructor 'constructor')}}..."
  // In older Handlebars or without runtime options, this accesses Function constructor
  // and can call Function("process.exit()")()
  const compiled = Handlebars.compile(template);
  const output = compiled(data);
  return { content: [{ type: "text", text: output }] };
});

// Handlebars sandbox escape (pre-4.7.7 and without runtime options):
// Template: {{#with (lookup (lookup this "constructor") "constructor")}}
//             {{#each (call this "return process.env")}}
//               {{@key}}: {{this}}
//             {{/each}}
//           {{/with}}
// This exfiltrates all environment variables via the process object

// The fix: user input is CONTEXT DATA, never the template string
// Templates are pre-compiled at server startup from trusted files
const TRUSTED_TEMPLATES = {
  report: Handlebars.compile(
    // Template loaded from a file in the repo — never from user input
    "<h1>Report: {{name}}</h1><p>Value: {{value}}</p>"
  ),
  summary: Handlebars.compile("Summary for {{name}}: {{value}} units"),
};

// Handlebars 4.7.7+ with runtime options — prevent prototype/constructor access
Handlebars.registerHelper("lookup", (obj, field) => {
  // Override lookup helper to block prototype access
  if (!Object.prototype.hasOwnProperty.call(obj, field)) return undefined;
  return obj[field];
});

server.tool("render_report", {
  // User can only specify WHICH pre-compiled template to use
  templateName: z.enum(["report", "summary"]),
  data: z.object({ name: z.string().max(128), value: z.number() }),
}, async ({ templateName, data }) => {
  const compiled = TRUSTED_TEMPLATES[templateName];
  // Render with allowProtoPropertiesByDefault disabled (default in Handlebars 4.7.7+)
  const output = compiled(data, {
    allowProtoPropertiesByDefault: false,
    allowProtoMethodsByDefault: false,
  });
  return { content: [{ type: "text", text: output }] };
});

2. Nunjucks SSTI

Nunjucks's renderString accepts an arbitrary template string and renders it. If user-supplied content is passed as the template string (rather than as data), attackers can access global objects through Nunjucks's expression syntax and achieve code execution in certain Nunjucks configurations:

import nunjucks from "nunjucks";

// VULNERABLE: user content passed as the template string to renderString
server.tool("format_message", {
  messageTemplate: z.string(),  // User controls the template — SSTI
  userName: z.string(),
}, async ({ messageTemplate, userName }) => {
  // Attacker passes: {{range.constructor("return global")().process.env.SECRET}}
  // Or: {{[].constructor.constructor("return process")().env.SECRET}}
  // These access the global object through constructor chains in Nunjucks

  const output = nunjucks.renderString(messageTemplate, { userName });
  return { content: [{ type: "text", text: output }] };
});

// SECURE: pre-compile templates from trusted sources at startup
// User data is only ever passed as the context object, never as the template
const nunjucksEnv = nunjucks.configure("./templates", {
  autoescape: true,         // HTML-escape all output by default
  throwOnUndefined: false,  // Don't throw on missing variables
  noCache: false,           // Cache compiled templates
});

// Template file: ./templates/message.njk
// Content: "Hello, {{ userName | e }}! Your message: {{ content | e }}"
// The '| e' filter ensures HTML escaping even if autoescape is somehow disabled

server.tool("format_message", {
  // User specifies WHICH template from the pre-registered set
  templateName: z.enum(["message", "welcome", "notification"]),
  userName: z.string().max(128),
  content: z.string().max(1000),
}, async ({ templateName, userName, content }) => {
  // renderString is NOT used — render() loads from the trusted templates directory
  const output = nunjucksEnv.render(`${templateName}.njk`, {
    userName,
    content,
    // Never pass a 'global', 'process', or 'require' key in the context
  });
  return { content: [{ type: "text", text: output }] };
});

3. EJS SSTI — the unescaped tag trap

EJS has two output tags: <%= %> (HTML-escaped, safe) and <%- %> (unescaped, dangerous). Using the unescaped tag with user-supplied content renders arbitrary HTML and — if the content contains EJS tags when the template is itself user-supplied — executes arbitrary JavaScript. Even the safe tag is dangerous when user input is used as the template string:

import ejs from "ejs";

// VULNERABLE 1: using unescaped tag <%- %> with user content in pre-written template
// In a pre-written template file:
// <!-- WRONG: templates/unsafe.ejs -->
// <div><%-  userContent %></div>
// If userContent = "<script>...</script>", XSS happens
// If template is rendered server-side and output used further, worse attacks follow

// VULNERABLE 2: user input AS the template string (always RCE)
server.tool("generate_doc", {
  docTemplate: z.string(),  // User controls the EJS template — SSTI/RCE
  title: z.string(),
}, async ({ docTemplate, title }) => {
  // Attacker passes: <%- global.process.mainModule.require('child_process').execSync('id') %>
  // This executes shell commands on the server
  const output = ejs.render(docTemplate, { title });
  return { content: [{ type: "text", text: output }] };
});

// SECURE: always use <%= %> (escaped), never <%- %> with user data
// Pre-compile templates at startup from trusted template files
const TEMPLATES: Record<string, ejs.ClientFunction> = {
  report: ejs.compile(
    // CORRECT: <%= %> escapes HTML — user data cannot inject EJS tags
    "<h1><%= title %></h1><p><%= content %></p>",
    { escape: (str) => String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") }
  ),
  email: ejs.compile(
    "Dear <%= recipientName %>,\n\n<%= body %>",
    {}
  ),
};

server.tool("generate_doc", {
  templateName: z.enum(["report", "email"]),
  title: z.string().max(256),
  content: z.string().max(5000),
}, async ({ templateName, title, content }) => {
  const template = TEMPLATES[templateName];
  // Render with user data as context — NOT as the template string
  const output = template({ title, content });
  return { content: [{ type: "text", text: output }] };
});

4. The correct pattern: user input as data context, never template string

The universal SSTI defense is architectural: user input is always passed as the data context to a template engine, never as the template string. The template string is always pre-compiled from trusted files at server startup time:

// Universal safe template pattern for MCP tool handlers
import { readFileSync } from "fs";
import { resolve } from "path";
import Handlebars from "handlebars";

// RULE: templates are compiled ONCE at startup from files in the repo
// They are NEVER compiled at request time from user-supplied strings

function loadTemplates(dir: string): Record<string, HandlebarsTemplateDelegate> {
  // Load only .hbs files from a specific directory — no traversal
  const templateDir = resolve(dir);
  const templates: Record<string, HandlebarsTemplateDelegate> = {};

  for (const name of ["report", "summary", "notification", "invoice"]) {
    const filePath = resolve(templateDir, `${name}.hbs`);
    // Verify the resolved path is within the template directory (path traversal prevention)
    if (!filePath.startsWith(templateDir + "/")) {
      throw new Error(`Template path traversal detected: ${filePath}`);
    }
    const source = readFileSync(filePath, "utf-8");
    templates[name] = Handlebars.compile(source, {
      strict: true,             // Throw on unknown variables
      noEscape: false,          // Always HTML-escape {{expr}}
    });
  }
  return templates;
}

// Compiled once at startup
const TEMPLATES = loadTemplates("./templates");

// At request time: only select which pre-compiled template to use
// and supply user data as the context
function renderTemplate(
  templateName: keyof typeof TEMPLATES,
  context: Record<string, unknown>
): string {
  const template = TEMPLATES[templateName];
  if (!template) throw new Error(`Unknown template: ${templateName}`);

  // Strip any keys from context that could be used for prototype access
  const safeContext = Object.fromEntries(
    Object.entries(context).filter(([key]) =>
      key !== "__proto__" && key !== "constructor" && key !== "prototype"
    )
  );

  return template(safeContext, {
    allowProtoPropertiesByDefault: false,
    allowProtoMethodsByDefault: false,
  });
}

5. Static pre-compilation and output HTML escaping

Pre-compiling templates at startup eliminates any request-time compilation from user input. Additionally, always HTML-escape template output even when using safe engines — defense-in-depth against any unexpected template evaluation:

// Pre-compilation at startup: templates compiled to JS functions in the build
// For Handlebars: use handlebars precompile in the build pipeline
// For Nunjucks: use nunjucks.precompile() and load compiled templates
// For EJS: use ejs.compile() during server init, cache the ClientFunction

// Output HTML escaping — defense in depth
function escapeHtml(str: string): string {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;");
}

// For MCP tool results that include rendered template output:
server.tool("render_invoice", {
  templateName: z.enum(["invoice", "receipt"]),
  lineItems: z.array(z.object({
    description: z.string().max(256),
    quantity: z.number().int().min(1).max(10000),
    unitPrice: z.number().min(0).max(1_000_000),
  })).max(50),
  customerName: z.string().max(128),
}, async ({ templateName, lineItems, customerName }) => {
  // Render using pre-compiled trusted template
  const rendered = renderTemplate(templateName, {
    customerName,
    lineItems,
    total: lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0),
    generatedAt: new Date().toISOString(),
  });

  // Even after safe rendering, HTML-escape the result when it will be
  // embedded in another context (e.g., returned inside a JSON string
  // that might be displayed in a web UI)
  return {
    content: [{
      type: "text",
      text: rendered, // Template engine already escaped; log separately if needed
    }],
  };
});

SkillAudit findings and grade impacts

Finding → Grade Impact
Critical User input passed as template string to Handlebars.compile(), nunjucks.renderString(), or ejs.render() — SSTI enabling full RCE. −25 points.
Critical Handlebars allowProtoPropertiesByDefault not disabled — prototype chain access enables sandbox escape and arbitrary code execution. −22 points.
High EJS <%- (unescaped tag) used with user-controlled context data — raw user content rendered into output without HTML escaping. −15 points.
High Template compilation at request time from dynamic sources — Handlebars.compile() or ejs.compile() called inside a request handler with non-static input. −12 points.
High No output encoding on template results — rendered output not HTML-escaped before embedding in HTML contexts, enabling stored XSS from template output. −10 points.
Medium Template engine errors exposed in API response — Handlebars or EJS error messages reveal template structure, file paths, or variable names to callers. −6 points.

Audit your MCP server for server-side template injection. SkillAudit's static analysis traces data flow from tool arguments into template engine calls — flagging renderString, compile(userInput), and unescaped EJS tags with user-controlled context variables. Run a free audit →