Back to Blog
critical SEVERITY7 min read

How Server-Side Template Injection happens in Node.js EJS and how to fix it

CVE-2022-29078 is a critical server-side template injection (SSTI) vulnerability in EJS versions prior to 3.1.7, where the `outputFunctionName` option is passed directly into generated code without sanitization, allowing attackers to execute arbitrary JavaScript on the server. The fix upgrades the EJS dependency from 2.7.4 to 3.1.7+ (resolved here as 6.0.1), eliminating the unsafe code generation path. Any Node.js application rendering EJS templates with user-influenced options is at risk of ful

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

CVE-2022-29078 is a critical Server-Side Template Injection (SSTI) vulnerability (CWE-94) in the EJS templating library for Node.js, affecting versions before 3.1.7. The root cause is that the `outputFunctionName` render option is interpolated directly into generated JavaScript code without validation, allowing an attacker who can control template options to inject and execute arbitrary server-side code. The fix is to upgrade EJS to version 3.1.7 or later, which validates the `outputFunctionName` value before using it in code generation. In this repository, the dependency was upgraded from `2.7.4` to `6.0.1` in both `package.json` and `package-lock.json`.

Vulnerability at a Glance

cweCWE-94 (Improper Control of Generation of Code)
fixUpgrade EJS from 2.7.4 to 3.1.7+ (resolved as 6.0.1), which validates outputFunctionName before code generation
riskRemote code execution on the server hosting the Node.js application
languageJavaScript / Node.js
root causeEJS interpolates the `outputFunctionName` render option directly into generated JavaScript without sanitization or validation
vulnerabilityServer-Side Template Injection (SSTI) via outputFunctionName

How Server-Side Template Injection Happens in Node.js EJS and How to Fix It


Introduction

The package-lock.json file in this Node.js project locked the application to EJS version 2.7.4 — a version containing one of the most dangerous classes of vulnerability a web application can carry: server-side template injection (SSTI). Trivy's scanner flagged rule CVE-2022-29078 against this dependency, and for good reason. A single unsanitized option — outputFunctionName — in EJS's template compilation pipeline could allow an attacker to execute arbitrary JavaScript directly on the server, with all the privileges of the Node.js process.

This post breaks down exactly how the vulnerability works, what the outputFunctionName option does, how an attacker could exploit it, and how upgrading to EJS 6.0.1 (which satisfies the >=3.1.7 requirement) closes the hole.


The Vulnerability Explained

What is EJS and what does outputFunctionName do?

EJS (Embedded JavaScript Templates) is one of the most widely used server-side templating engines in the Node.js ecosystem. It compiles .ejs template files into JavaScript functions that render HTML. One of its lesser-known render options is outputFunctionName, which lets developers replace the default print / __append function used internally during template code generation.

In EJS 2.7.4, the value of outputFunctionName is inserted directly into the generated JavaScript source string during compilation — without any validation that the value is a safe identifier. Here is the critical pattern in the vulnerable EJS source (simplified for clarity):

// EJS 2.x — VULNERABLE (simplified from actual source)
if (opts.outputFunctionName) {
  prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

The string opts.outputFunctionName is concatenated verbatim into JavaScript source code that is subsequently passed to the Function() constructor (EJS's template compilation mechanism). This is textbook CWE-94: Improper Control of Generation of Code.

The attack: injecting code through outputFunctionName

If an attacker can influence the value of outputFunctionName — for example, through a query parameter, a JSON body field, or any application logic that passes user input into EJS render options — they can break out of the variable declaration and inject arbitrary JavaScript. A minimal proof-of-concept payload looks like this:

outputFunctionName = "x; process.mainModule.require('child_process').execSync('id > /tmp/pwned'); //

When EJS compiles a template with this option set, the generated code becomes:

// What EJS 2.7.4 actually generates from the malicious option
var x; process.mainModule.require('child_process').execSync('id > /tmp/pwned'); // = __append;

That generated string is then evaluated by the Function() constructor, executing execSync('id > /tmp/pwned') on the server. No template content manipulation is required — the exploit lives entirely in the render options.

Real-world impact for this application

This project uses EJS alongside cheerio, jsdom, and leancloud-storage — a stack that suggests server-side HTML processing, possibly including user-submitted or remotely fetched content. Any code path that:

  1. Accepts user input (HTTP parameters, request body, external API responses), and
  2. Passes that input — directly or indirectly — into EJS render options

...creates a direct path to remote code execution. Because the project is flagged as production code (not test-only), the risk is live and exploitable.


The Fix

What changed

Two files were modified to eliminate the vulnerable dependency:

package.json — the declared dependency range was updated:

// Before
"ejs": "^2.7.4"

// After
"ejs": "^6.0.1"

package-lock.json — the resolved version was pinned to the safe release:

// Before
"node_modules/ejs": {
  "version": "2.7.4",
  "resolved": "https://mirrors.huaweicloud.com/repository/npm/ejs/-/ejs-2.7.4.tgz",
  "integrity": "sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==",
  "hasInstallScript": true,
  "engines": { "node": ">=0.10.0" }
}

// After
"node_modules/ejs": {
  "version": "6.0.1",
  "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
  "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
  "engines": { "node": ">=0.12.18" }
}

Why the fix works

EJS 3.1.7 introduced a validation check on outputFunctionName before it is ever interpolated into generated code. The patched source validates that the option value matches a safe JavaScript identifier pattern (a strict regex allowlist), and throws an error if it does not:

// EJS 3.1.7+ — SAFE (from patched source)
if (opts.outputFunctionName) {
  if (!_JS_IDENTIFIER.test(opts.outputFunctionName)) {
    throw new Error('outputFunctionName is not a valid JS identifier.');
  }
  prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

The _JS_IDENTIFIER regex only permits valid JavaScript identifier characters — no semicolons, no parentheses, no shell commands. The injection vector is completely closed.

Two files needed to change because package.json controls what npm install resolves going forward, while package-lock.json controls the exact version currently installed. Updating only one would leave the other out of sync, potentially reinstalling the vulnerable version on the next npm ci run.


Key Takeaways

  • outputFunctionName is a code-generation sink, not a data field. In EJS 2.x, this option is interpolated verbatim into JavaScript source before Function() evaluation — making it a direct RCE vector if user-controlled.
  • Upgrading package.json alone is not enough. The package-lock.json must also be updated to guarantee the safe version is installed in all environments, including CI/CD pipelines running npm ci.
  • SSTI bypasses output escaping entirely. Developers who rely on EJS's <%- %> vs <%= %> escaping to prevent injection are not protected against outputFunctionName-based attacks, which occur at compile time.
  • The jump from 2.7.4 to 6.0.1 also removed hasInstallScript: true. The vulnerable version ran install scripts on package installation — an additional attack surface that the upgraded version eliminates.
  • Static analysis caught what code review missed. Trivy's dependency scanning flagged this CVE against the lock file entry, demonstrating that SCA tooling is essential for transitive and direct dependency security.

How Orbis AppSec Detected This

  • Source: The outputFunctionName EJS render option, which can be influenced by HTTP request parameters or application logic that passes external data into template render calls.
  • Sink: EJS's internal template compilation pipeline in node_modules/ejs version 2.7.4, where outputFunctionName is concatenated into a JavaScript source string passed to the Function() constructor.
  • Missing control: No validation or allowlisting of the outputFunctionName value before it was interpolated into generated code — the identifier regex check (_JS_IDENTIFIER) introduced in EJS 3.1.7 was entirely absent.
  • CWE: CWE-94 — Improper Control of Generation of Code.
  • Fix: Upgraded EJS from 2.7.4 to 6.0.1 in both package.json and package-lock.json, resolving to a version that validates outputFunctionName against a strict JavaScript identifier regex before use.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

CVE-2022-29078 is a stark reminder that template engine options can be just as dangerous as template content. In EJS 2.7.4, the outputFunctionName option was a direct bridge from application configuration into code execution — and any application that allowed user input to reach that option was fully compromised. The fix is straightforward: upgrade to EJS 3.1.7 or later, where a single identifier validation regex closes the attack vector entirely. More broadly, treat all template engine options as trusted configuration, commit your lock files, and integrate dependency scanning into your CI pipeline so that CVEs like this are caught before they ever reach production.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1521

Related Articles

high

CVE-2026-4800: lodash Template Imports Allow Code Execution

CVE-2026-4800 affects lodash's `_.template()` templating API, where untrusted input reaching the `imports` option can lead to arbitrary code execution. The fix was shipped as a dependency upgrade from lodash 4.17.21 to 4.18.1 in the project's lockfile, though the PR itself notes it was never verified against the actual code paths in this repository.

critical

proxy-addr 2.0.7 IP Spoofing: CVE-2026-90711 Trust Bypass

A critical vulnerability in proxy-addr 2.0.7 allowed attackers to spoof client IP addresses by manipulating X-Forwarded-For headers when the trust chain evaluation contained specific misconfigurations. The fix in version 2.0.8 hardens the trust evaluation logic to prevent IP address falsification in Express.js applications relying on this common middleware dependency.

high

TweenMax `_applyCycle` Prototype Pollution via vars.cycle Keys

A bundled copy of the TweenMax animation library copied attacker-influenceable `vars.cycle` property names straight onto a tween configuration object using an unguarded `for...in` loop, so a key named `__proto__`, `constructor`, or `prototype` was written through to the object's prototype chain. The fix adds an explicit key denylist to both copies of the `_applyCycle` helper so those three names are skipped during the merge. No CVE or GHSA is assigned; the issue is tracked as CWE-1321 (Improperl

high

picomatch 2.3.1 ReDoS: Extglob Pattern Catastrophic Backtracking

picomatch versions below 2.3.2, 3.0.2, or 4.0.4 contain a Regular Expression Denial of Service vulnerability in extglob pattern parsing. An attacker can cause catastrophic backtracking with patterns containing nested alternations and quantifiers, freezing any Node.js process that evaluates untrusted glob expressions.

critical

pet-window.js Dynamic Code Evaluation: CWE-94 Hardening via Number

The pet-window module constructed dynamic JavaScript by embedding raw configuration values into code strings. An attacker with local access could inject arbitrary JavaScript by modifying stored configuration. The fix replaces string interpolation with explicit Number() coercion and NaN validation for all numeric configuration parameters.

high

sanitizeUnicodeInput(): Fullwidth U+ Bypasses Codepoint Validation

The `sanitizeUnicodeInput()` helper used by the project character-range settings screen rewrote `U+` prefixes to `0x` and called `parseInt()`, but never normalized its argument first. Compatibility-equivalent forms such as fullwidth `U+`, superscript digits, or mathematical alphanumerics never matched the `/U\+/gi` regex, fell through to the `else return inputString` branch, and were handed back to callers verbatim as "sanitized" values. The fix inserts a `String.prototype.normalize('NFKC')` pas