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
Published August 26, 2026Reviewed 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.


Prevention & Best Practices

1. Never pass user-controlled data as template engine options

The most direct mitigation is architectural: treat render options (like outputFunctionName, delimiter, escape, root) as trusted configuration, not as data. These options influence code generation, not just output rendering.

// DANGEROUS — user input reaches render options
app.get('/render', (req, res) => {
  ejs.render(template, data, { outputFunctionName: req.query.fn });
});

// SAFE — options are hardcoded or validated
app.get('/render', (req, res) => {
  ejs.render(template, data, { /* no user-controlled options */ });
});

2. Keep templating libraries up to date

EJS is a transitive dependency in many projects. Use npm audit, Dependabot, or a dedicated SCA scanner (like Trivy, Snyk, or Socket.dev) to detect vulnerable versions before they reach production.

# Run npm audit to surface known vulnerabilities
npm audit

# Fix automatically where possible
npm audit fix

3. Pin your lock file and commit it

The package-lock.json was part of this fix for a reason. Committing your lock file ensures that npm ci in CI/CD always installs the exact audited version — not a range that could silently resolve to a vulnerable release.

4. Apply the principle of least privilege to the Node.js process

If RCE does occur, its blast radius depends on the process's OS-level permissions. Run Node.js applications as a dedicated, low-privilege user — never as root. Use container security contexts (runAsNonRoot: true) in Kubernetes environments.

5. Relevant standards

  • OWASP A03:2021 – Injection: SSTI is a subset of injection attacks. See the OWASP Injection cheat sheet.
  • CWE-94: Improper Control of Generation of Code — the direct classification for this vulnerability.
  • CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component — the parent weakness.

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.


References

Frequently Asked Questions

What is server-side template injection (SSTI)?

SSTI occurs when user-controlled input is embedded into a server-side template in a way that allows the attacker to inject template directives or raw code, which the template engine then executes on the server.

How do you prevent SSTI in Node.js EJS applications?

Upgrade EJS to 3.1.7 or later, never pass user-controlled data as EJS render options (especially outputFunctionName), and validate or allowlist any option values that influence code generation.

What CWE is server-side template injection?

SSTI is classified under CWE-94 (Improper Control of Generation of Code), and may also relate to CWE-74 (Improper Neutralization of Special Elements in Output Used by a Downstream Component).

Is escaping template output enough to prevent SSTI in EJS?

No. Output escaping prevents XSS in rendered HTML but does not protect against SSTI, which exploits the template engine's code generation phase — before any output escaping takes place.

Can static analysis detect SSTI vulnerabilities in EJS?

Yes. Tools like Trivy (which flagged this issue), Snyk, and Semgrep can detect known vulnerable versions of EJS and flag dangerous patterns where render options are derived from user input.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1521

Related Articles

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

high

How JavaScript Injection via String Interpolation Happens in Go Wails Applications and How to Fix It

A high-severity JavaScript injection vulnerability in `internal/clusterconfigs/input.go` allowed arbitrary code execution through malicious kubeconfig filenames. The `saveClusterConfigFile` function at line 20 constructed JavaScript code by directly interpolating unsanitized filenames into `window.ExecJS()` calls, enabling attackers to break out of string literals and execute arbitrary JavaScript in the Webview context.

high

How Denial of Service via Prototype Pollution happens in Axios and how to fix it

Axios versions prior to 1.15.1 merged untrusted configuration objects without guarding against the `__proto__` key, letting attacker-controlled input pollute `Object.prototype` and crash or destabilize applications. Upgrading axios (and its transitive dependencies `form-data`, `follow-redirects`, `proxy-from-env`) closes this Denial of Service and prototype-pollution attack surface without changing any application code.

critical

How Server-Side Request Forgery happens in Node.js and how to fix it

The order-flow service in a Node.js e-commerce backend built an outbound fetch() URL by directly concatenating a configurable `sendingOrder.url` value with a query string, with no validation of protocol or destination. This allowed order data—including customer and payment-adjacent information—to be silently redirected to an attacker-controlled endpoint simply by changing a config value or environment variable.

high

How Infinite Loop Denial of Service Happens in nanoid and How to Fix It

CVE-2026-67213 is a high-severity infinite loop vulnerability in nanoid's `customAlphabet` function that could cause Denial of Service through CPU exhaustion. The fix upgrades nanoid from 3.3.12 to patched versions 3.3.18 and 5.1.6, eliminating the loop condition that trapped ID generation when processing certain input patterns.

critical

How Message Corruption via Protocol Length Header Abuse Happens in WebSocket Implementations and How to Fix It

CVE-2026-54466 is a critical vulnerability in websocket-driver 0.7.4 that allows attackers to corrupt WebSocket messages by abusing protocol length headers. The fix upgrades the package to version 0.7.5, which implements proper validation of untrusted length header inputs. This vulnerability could allow attackers to modify or inject data into real-time communication channels used by frontend applications.