Back to Blog
SecurityJuly 3, 20264 min read

Node.js Security: Preventing Template Injection and RCE

Node.js security depends on preventing template injection. Learn how to stop RCE via EJS and Pug variable pollution by isolating user input from your templates.

Node.jsSecurityEJSPugRCEWeb DevelopmentWebBackend

During an on-call rotation last year, I spent about four hours tracing a weird production crash that turned out to be a classic case of improper variable handling in our EJS templates. An attacker had passed a maliciously crafted object through a query parameter, which our controller blindly merged into the template locals, leading to a prototype pollution vulnerability that effectively allowed for arbitrary code execution.

If you're building with Node.js, you've likely used template engines like EJS or Pug. They’re convenient, but they often treat the "locals" object as a trusted sandbox, which is a dangerous assumption. When user-controlled data reaches these engines without strict sanitization, you aren't just looking at XSS; you're looking at full remote code execution.

Understanding the Node.js Security Risk

Template engines are designed to parse strings and execute logic. When you pass an object to res.render(), that object becomes the scope for the template. If an attacker can inject properties into that object—often through Object.assign() or deep-merging libraries—they can overwrite internal engine settings.

In EJS, for instance, there are internal options that control how the engine compiles the template. If you allow a user to influence the settings or the locals object, they might be able to inject JavaScript that the engine evaluates during the rendering phase. We previously discussed the broader implications of Server-Side Template Injection: A Practical Guide to Prevention, and the pattern is consistent: if you don't treat data as untrusted, the engine will eventually execute it as code.

The Pitfall: Blind Object Merging

Early in my career, I frequently used simple object spreading to pass data to views. It looked clean:

JAVASCRIPT
// The dangerous pattern
app.get(CE9178">'/profile', (req, res) => {
  const defaults = { theme: CE9178">'dark', user: req.user };
  const data = { ...defaults, ...req.query }; // VULNERABLE
  res.render(CE9178">'profile', data);
});

If a user sends a request with ?__proto__.outputFunctionName=x;return;process.mainModule.require('child_process').exec('whoami');x=1, they can manipulate the internal function that EJS uses to generate output. This is a common template injection vector. By polluting the prototype or simply overwriting the local scope, the attacker forces the engine to execute their payload.

Strategies for RCE Prevention

To stop this, you have to stop passing raw request objects directly into your render functions. Here is how I handle it now:

  1. Whitelist, don't blacklist: Create a new object and explicitly pick the fields you need.
  2. Avoid deep-merging user input: If you must merge, use a library that ignores __proto__ and constructor keys, or better yet, don't merge user input at all.
  3. Context-Aware Encoding: Always ensure your template engine is configured to auto-escape HTML. For more on this, check out my notes on XSS Prevention: Mastering Context-Aware Template Sanitization.

Here is a simple comparison of how to safely vs. unsafely expose data:

ApproachRisk LevelWhy?
{ ...req.query }CriticalAllows prototype pollution and function overrides.
Object.assign({}, req.query)HighStill allows shallow property injection.
pick(req.query, ['name', 'id'])LowExplicitly limits what hits the template.

Why Template Injection Happens

The core issue is that template engines are essentially compilers. They take your template file and turn it into a JavaScript function. If an attacker can inject code into the context of that function, the engine will execute it with the same privileges as your Node.js process.

Flow diagram: User Input → Sanitization Layer; B -- Unsafe → Template Locals Object; B -- Safe → Filtered Payload; Template Locals Object → Template Engine; Filtered Payload → Template Engine; Template Engine → Rendered Output; E -- Vulnerable → RCE

This flow shows why the "Sanitization Layer" is the only thing standing between your app and an RCE event. If the data is not strictly filtered, the engine becomes an unwitting accomplice to the attacker.

Frequently Asked Questions

Q: Is Pug safer than EJS? A: Not inherently. Both engines are susceptible to injection if you pass unvalidated user input. Pug’s syntax is more restrictive, which can make certain types of XSS harder, but it doesn't solve the core issue of variable pollution leading to RCE.

Q: Does using a modern Node.js version protect me? A: While newer versions of Node.js have mitigations for prototype pollution in some core modules, they cannot protect you from how you use third-party template engines. You still need to write defensive code.

Q: What if I need to pass complex objects? A: Use a DTO (Data Transfer Object) pattern. Define a schema for your template data and map your input to that schema before passing it to the render function.

I’m still experimenting with stricter Content Security Policies (CSP) to see if they can act as a final safety net for template-based rendering. It’s not a replacement for good code hygiene, but it’s a solid secondary layer. If you're building something critical, assume the template engine is a sandbox you need to keep locked down at all times.

Similar Posts