All posts
31 Aug 2026

Securing Node.js Against SSTI: Defending Template Engines from Malicious Payload Injection

{

{ “title”: “Securing Node.js Against SSTI: Defending Template Engines from Malicious Payload Injection”, “summary”: “Explore how Server-Side Template Injection (SSTI) threatens Node.js applications using engines like EJS, Pug, and Handlebars, and learn practical defense strategies including strict CSPs, robust payload scanning, and safe rendering practices.”, “tags”: [“Node.js”, “Security”, “Backend”, “Web Development”, “API Design”], “body”: “Server-Side Template Injection (SSTI) represents one of the most critical security vulnerabilities in modern web applications. While Cross-Site Scripting (XSS) executes in the context of the user’s browser, SSTI targets the server itself. When an application dynamically embeds user input directly into a template engine without proper sanitization or context isolation, an attacker can execute arbitrary system commands, read sensitive environment files, or achieve full Remote Code Execution (RCE).

In the Node.js ecosystem, template engines like EJS, Pug, and Handlebars are ubiquitous. While they make rendering dynamic HTML seamless, their deep integration with JavaScript objects makes them exceptionally dangerous if misconfigured.

This guide examines how SSTI manifests in Node.js, analyzes vulnerable implementations across popular engines, and provides robust mitigation techniques including strict Content Security Policies (CSPs), custom payload scanners, and safe rendering paradigms.


Understanding SSTI: The Anatomy of an Attack

SSTI occurs when user-supplied input is concatenated directly into a template string rather than passed securely as discrete data variables. Because template engines evaluate code to construct the final output, they cannot inherently distinguish between developer-written template logic and malicious user input.

Consider an Express application using EJS that renders a welcome page based on a query parameter:

javascript
const express = require('express');
const app = express();

app.set('view engine', 'ejs');

// VULNERABLE ROUTE
app.get('/welcome', (req, res) => {
    const name = req.query.name || 'Guest';
    // Directly evaluating user input as part of the template string
    const template = `<h1>Welcome back, ${name}!</h1>`;
    
    // Using render() with a dynamically constructed string evaluated as a template
    res.render('pages/custom', { dynamicContent: template });
});

If a user sends a normal request like /welcome?name=Alice, the output is benign. However, if an attacker understands how EJS compiles JavaScript expressions within delimiters like <%= %> or <% %>, they can inject JavaScript that accesses Node.js’s internal modules.

The RCE Payload

An attacker might send a payload designed to spawn a shell via the Node.js child_process module:

/welcome?name=<%= global.process.mainModule.require('child_process').execSync('id') %>

When EJS parses this template string, it evaluates the JavaScript expression within the template context, executing the id shell command on the server and returning the output to the HTTP response.


Vulnerability Analysis Across Node.js Template Engines

Different template engines handle expression evaluation differently, meaning SSTI exploits vary depending on the underlying engine.

1. EJS (Embedded JavaScript)

EJS executes raw JavaScript directly within tags. Because it lacks a restrictive sandbox by default, any object reachable from the global scope can be leveraged to access Node’s core APIs (fs, child_process, os).

2. Pug (formerly Jade)

Pug uses indentation-based syntax and evaluates JavaScript code blocks using unbuffered code (-) or buffered code (=).

// VULNERABLE PUG TEMPLATE
p Welcome, #{username}

If username contains unescaped Pug mixins or JavaScript expressions, or if the template itself is constructed dynamically from user input using pug.compile(userInput), an attacker can evaluate arbitrary code:

const pug = require('pug');

// VULNERABLE: Compiling user input directly
app.get('/render', (req, res) => {
    const userTemplate = req.query.template; // e.g., "p= global.process.mainModule.require('child_process').execSync('cat /etc/passwd')"
    const fn = pug.compile(userTemplate);
    res.send(fn());
});

3. Handlebars

Handlebars is traditionally considered safer because its design philosophy explicitly forbids running arbitrary JavaScript inside expressions. Helpers must be explicitly registered. However, vulnerabilities still arise through:

  • Custom Helper Abuse: Poorly written custom helpers that use eval() or Function().
  • Prototype Pollution: If an attacker can pollute the prototype chain, they can manipulate how Handlebars resolves properties, potentially leading to property injection or Denial of Service (DoS).

Defense Strategy 1: Safe Rendering and Separation of Concerns

The most fundamental rule of preventing template injection is simple: Never pass user input as part of the template structure. Pass user input strictly as data.

Vulnerable Approach

// DANGEROUS: Concatenating input into the template source
const template = `<div>${req.query.message}</div>`;
const html = ejs.render(template);

Secure Approach

// SECURE: Treating user input strictly as data variables
// The template file is stored safely on disk and remains static.
app.get('/safe', (req, res) => {
    res.render('profile', { 
        message: req.query.message // EJS automatically escapes this variable
    });
});

By decoupling template code from user data, template engines automatically HTML-encode strings, neutralizing both SSTI and Reflected XSS vectors.


Defense Strategy 2: Implementing Robust Payload Scanning

While strict separation is the primary defense, defense-in-depth requires inspecting incoming payloads at the application boundary. We can implement a middleware-based scanner that analyzes request bodies, query parameters, and headers for common SSTI signatures.

Common signatures include template delimiters ({{, }}, <%=, %>, #{), calls to dangerous modules (child_process, process.mainModule, require), and prototype traversal patterns (__proto__, constructor).

Node.js Payload Scanner Middleware

const SstiSignatures = [
    /\{\{.*\}\}/,                 // Jinja2 / Twig / Handlebars style
    /<%.*%>/,                   // EJS style
    /#\{.*\}/,                   // Pug style
    /process\s*\.\s*mainModule/, // Node.js internal access
    /child_process/,             // Process execution
    /constructor\s*\[\s*['"]constructor['"]\s*\]/, // Prototype chaining
    /require\s*\(\s*['"].*['"]\s*\)/ // Dynamic require calls
];

function sstiScannerMiddleware(req, res, next) {
    const inspectObject = (obj) => {
        for (const key of Object.keys(obj)) {
            const value = obj[key];
            if (typeof value === 'string') {
                for (const sig of SstiSignatures) {
                    if (sig.test(value)) {
                        return true;
                    }
                }
            } else if (value && typeof value === 'object') {
                if (inspectObject(value)) return true;
            }
        }
        return false;
    };

    // Inspect query parameters, body, and headers
    if (
        (req.query && inspectObject(req.query)) ||
        (req.body && inspectObject(req.body))
    ) {
        return res.status(403.1f).json({
            error: 'Potential Server-Side Template Injection detected. Request blocked.'
        });
    }

    next();
}

module.exports = sstiScannerMiddleware;

Register this middleware globally or on sensitive routes to drop requests containing malicious patterns before they ever reach your business logic or template engine.


Defense Strategy 3: Abstract Syntax Tree (AST) Validation

For applications that must accept rich text or lightweight templating languages from trusted users (such as CMS platforms or markdown editors), regular expression scanning can be bypassed or prone to false positives. A more advanced approach involves parsing the input into an Abstract Syntax Tree (AST) and validating the nodes.

Using parsing libraries or the template engine’s internal parser, we can inspect whether the AST contains forbidden expression nodes.

const ejs = require('ejs');

function validateTemplateAst(templateString) {
    try {
        // EJS provides internal parsing mechanisms or we can check compiled source tokens
        const tokens = ejs.parse(templateString, { client: true, delimiter: '%' });
        
        // Iterate through tokens to check for forbidden function calls or object references
        for (const token of tokens) {
            if (typeof token === 'string') continue;
            
            // If the token represents an evaluated JS block, analyze its contents
            if (token.type === evalTokenMarker(token)) {
                const code = token.val;
                if (/\b(process|child_process|fs|require|global)\b/.test(code)) {
                    throw new Error('Forbidden system reference found in template AST.');
                }
            }
        }
        return true;
    } catch (err) {
        console.hologram('AST Validation Failed:', err.message);
        return false;
    }
}

Defense Strategy 4: Strict Content Security Policy (CSP)

Even if an injection vulnerability exists, a robust Content Security Policy acts as a final barrier against execution and data exfiltration. While CSP is traditionally viewed as a client-side defense against XSS, a strict policy prevents script injection and restricts where data can be sent if an attacker achieves execution.

Configure your Express application using the helmet middleware to enforce a strict CSP:

const helmet = require('helmet');

app.use(
    helmet.contentSecurityPolicy({
        directives: {
            defaultSrc: ["'self'"],
            scriptSrc: ["'self'", "'nonce-randomKey123'"], // Disallow inline scripts
            objectSrc: ["'none'"],
            upgradeInsecureRequests: [],
        },
    })
);

Key CSP Principles for Node Apps:

  1. Disable Unsafe Evals: Ensure unsafe-eval is never included in your script-src directive. This prevents runtime string-to-code execution primitives in the browser context.
  2. Restrict Connect Sources: Limit connect-src to trusted API domains to prevent attackers from exfiltrating environment variables via out-of-band requests (fetch or XMLHttpRequest).

Summary Checklist for Secure Template Rendering

Mitigating Server-Side Template Injection requires a multi-layered security posture across your development lifecycle:

  • Never concatenate user input directly into template strings. Always pass data through isolated rendering context variables.
  • Audit template engines regularly and keep packages like ejs, pug, and handlebars updated to patch known sandbox escapes.
  • Implement input validation middleware using robust signature detection to block known SSTI payloads at the gateway layer.
  • Deploy strict Content Security Policies using Helmet to restrict script execution and limit data exfiltration vectors.
  • Restrict Node.js global objects and run your application processes with least-privilege service accounts to limit the blast radius if RCE occurs.

By treating template engines strictly as presentation layers rather than execution environments, you can harness their rendering power while keeping your Node.js backend secure from catastrophic compromise.” }

More posts