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

high

How Prototype Pollution happens in JavaScript via defu and how to fix it

CVE-2026-35209 is a high-severity prototype pollution vulnerability in the `defu` JavaScript library (versions prior to 6.1.5), where a crafted `__proto__` key in the defaults argument can corrupt the global Object prototype. The fix upgrades `defu` from 6.1.4 to 6.1.5 in `pnpm-lock.yaml` and enforces the version via a workspace override, closing the attack surface in production code that depends on `defu` for deep object merging.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript

high

How Unsafe eval() in JavaScript Happens in React Components and How to Fix It

A high-severity code injection vulnerability was discovered in `TurnPlanner.tsx`, where the `parseInputExpr` function used JavaScript's `Function` constructor — effectively `eval()` — to evaluate user-provided mathematical expressions. The regex guard in place only checked for the presence of arithmetic operators, not whether the input was safe to execute, leaving the door open for arbitrary JavaScript injection. A targeted whitelist fix was applied to reject any input containing characters outs

high

How Prototype Pollution happens in Node.js and how to fix it

A high-severity prototype pollution vulnerability (CVE-2020-8203) was identified in the lodash library via the `zipObjectDeep` function, present as a transitive dependency through postcss in the project's `yarn.lock`. The fix upgrades postcss from 8.5.8 to 8.5.12 using a Yarn resolution override, eliminating the vulnerable lodash code path and reducing the attack surface against crafted CSS input. This change protects the application from object prototype manipulation that could lead to informat

critical

How Prototype Pollution happens in Node.js protobufjs and how to fix it

CVE-2023-36665 is a critical prototype pollution vulnerability in protobufjs that allows attackers to corrupt JavaScript's Object prototype by crafting malicious protobuf messages. The vulnerability existed in protobufjs 6.11.3 and was resolved by upgrading to 6.11.4 (and 7.2.5 for the v7 branch). Applications that parse user-supplied protobuf data are directly at risk of runtime behavior manipulation, privilege escalation, or denial of service.

critical

How Plaintext Credential Storage Happens in Node.js Config Files and How to Fix It

A critical vulnerability in `config.js` allowed OAuth tokens and user IDs to silently fall back to empty strings when environment variables were unset, enabling credential bypass and potential hardcoded secret exposure. The fix removes the `|| ""` fallback pattern, ensuring credentials are either properly set or explicitly `undefined`, and updates downstream checks to use truthy evaluation instead of empty-string comparison. This change closes a subtle but dangerous gap that could have allowed A