Safeguard
Vulnerability Analysis

EJS template engine RCE via client option (CVE-2022-29078)

CVE-2022-29078 lets attackers achieve remote code execution in EJS via unsanitized render options. Affected versions, severity, and fixes inside.

Nayan Dey
Security Researcher
7 min read

A remote code execution flaw in one of the JavaScript ecosystem's most widely embedded templating libraries, EJS, went largely unnoticed outside dependency-scanning circles for years — yet it sits in the dependency tree of thousands of Node.js applications, CLI scaffolding tools, and admin dashboards. Tracked as CVE-2022-29078, the bug allows an attacker who can influence the options object passed to ejs.render() or ejs.renderFile() to inject and execute arbitrary JavaScript on the server. Because EJS is frequently used to render server-side views from data that ultimately traces back to a query string, form field, or API payload, the practical blast radius is larger than a typical "theoretical" template-engine bug — full server compromise, data exfiltration, and lateral movement are all on the table when the vulnerable code path is reachable.

What Is CVE-2022-29078?

EJS (Embedded JavaScript templates) compiles .ejs template strings into a JavaScript function using Node's Function constructor under the hood. To make debugging and customization easier, EJS exposes several configuration options on the render call, including outputFunctionName and client. The outputFunctionName option lets a developer rename the internal helper function EJS uses to build output strings — for example, so the compiled template writes print(...) instead of the default __append(...).

The problem is that whatever string is supplied for outputFunctionName (and related option values used during compilation) gets concatenated directly into the generated JavaScript source before that source is compiled and executed. EJS never validated that the value was a safe identifier. If an application builds the options object from anything an attacker can influence — a merged req.query, a JSON body spread into the render call, a configuration value loaded from an untrusted source — the attacker can supply a string like:

x; process.mainModule.require('child_process').execSync('id') //

as the outputFunctionName value. EJS splices that string straight into the function body it compiles, and the malicious payload executes with the same privileges as the Node.js process rendering the template. This is a classic CWE-94 (Improper Control of Generation of Code) issue: user-controlled input crosses into a code-generation sink without validation or escaping, and it does not require the template source itself to be attacker-controlled — only the render options.

Affected Versions and Components

  • Package: ejs (npm)
  • Affected versions: All versions prior to 3.1.7
  • Fixed version: 3.1.7 and later
  • Root cause location: the compiler logic in lib/ejs.js that builds the internal function body from opts.outputFunctionName / related client-compilation options

Because ejs is a foundational dependency, the CVE's reach extends well past applications that import it directly. It ships as a transitive dependency inside numerous CLI scaffolding tools, static-site generators, admin panel boilerplates, and Express-based starter kits, many of which pin older, pre-fix versions in their own package-lock.json or bundle EJS inside compiled artifacts. Teams that never wrote a line of EJS themselves can still ship the vulnerable version buried three or four levels deep in node_modules. The exploitability of any given instance depends entirely on whether the surrounding application ever passes attacker-influenced data into the options argument of a render call — which is why reachability, not just presence, is the deciding factor for real-world risk.

Severity: CVSS, EPSS, and KEV Context

The GitHub Security Advisory (GHSA-ghr5-ch3p-vcr6) for this issue rates it Critical, with a CVSS v3.1 base score of 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) — network-exploitable, no privileges or user interaction required, and complete impact on confidentiality, integrity, and availability once the vulnerable option is reachable. Some downstream advisory feeds list a more conservative score closer to 8.1, reflecting the caveat that exploitation depends on an application-level condition (attacker control over the options object) rather than being exploitable purely through template content.

EPSS (Exploit Prediction Scoring System) probability for this CVE has historically sat in the low single digits, consistent with a vulnerability that requires a specific, non-default coding pattern to be exploitable rather than being trivially wormable out of the box. It is not currently listed in CISA's Known Exploited Vulnerabilities (KEV) catalog, meaning there is no confirmed record of widespread in-the-wild exploitation as of this writing. That said, EPSS and KEV status are lagging, aggregate signals — they describe internet-wide exploitation trends, not whether your specific deployment is reachable. A low EPSS score should not be read as "safe to ignore" when your own codebase demonstrably passes untrusted data into a render options object.

Timeline

  • Pre-2022: The unsanitized handling of outputFunctionName and related compile options exists in EJS's codebase for years without being flagged as a security-sensitive input.
  • April 2022: The EJS maintainers ship version 3.1.7, which sanitizes the relevant option values and rejects unsafe identifiers before they reach the code-generation step.
  • Late April 2022: GitHub publishes advisory GHSA-ghr5-ch3p-vcr6, and CVE-2022-29078 is assigned to formally track the issue.
  • Mid-2022: The CVE is ingested into NVD, OSV, and major SCA/vulnerability databases, propagating into dependency-scanning tools and triggering alerts across projects with pinned pre-3.1.7 versions.
  • Ongoing: Because EJS is embedded transitively across the npm ecosystem, the CVE continues to resurface in SBOM and SCA scans years later whenever an old lockfile, cached Docker layer, or vendored copy of EJS is discovered in a new codebase or acquisition target.

Remediation Steps

  1. Upgrade immediately. Bump ejs to 3.1.7 or later — ideally the current latest release — in every package.json where it's a direct dependency. Run npm ls ejs (or your package manager's equivalent) to enumerate every transitive path that resolves to a vulnerable version, since lockfiles can pin stale copies independently across monorepo packages.
  2. Audit every render call site. Search your codebase for .render( and .renderFile( calls and inspect how the options argument is constructed. Flag any pattern that spreads request data, query parameters, or externally sourced configuration directly into that object, e.g. ejs.render(template, { ...req.query }).
  3. Allow-list, don't spread. Replace permissive object spreads with an explicit allow-list: construct a fresh options object containing only the specific template variables your view needs (e.g., { locals: { username: sanitizedUsername } }), and never forward the raw request body or query object as the options parameter.
  4. Reject reserved keys defensively. Even on patched versions, add a defense-in-depth check that strips or rejects options keys like outputFunctionName, client, escapeFunction, and compileDebug if they ever originate from untrusted input, in case future regressions or forks reintroduce similar behavior.
  5. Pin and lock. Use exact version pinning or a lockfile with integrity hashes so a future npm install can't silently reintroduce a vulnerable transitive copy of EJS through an unrelated dependency bump.
  6. Rebuild and redeploy affected containers. If EJS is baked into a Docker image, rebuild from a clean base rather than patching in place, and confirm the fix by re-scanning the resulting image, not just the source repository.
  7. Extend the check to vendored and bundled copies. Tools and admin scaffolds that bundle their own copy of EJS (rather than depending on it via npm) require manual patching or a vendor update — a standard npm audit won't catch these.

How Safeguard Helps

Finding "ejs < 3.1.7" in a scan is the easy part; knowing which of your dozens of services actually pass attacker-influenced data into a render options object is what determines whether this is a page-one incident or a backlog ticket. Safeguard's reachability analysis traces data flow from HTTP inputs and request handlers through to the vulnerable outputFunctionName/client sink, so you see exactly which deployed services are exploitable versus which merely carry the package as dead weight — cutting through the noise that a raw CVE match generates. Griffin AI reviews each flagged call site, explains the exploit path in plain language, and ranks remediation priority based on real exposure rather than CVSS alone. Safeguard's SBOM generation and ingest capabilities surface every direct and transitive copy of EJS across your fleet — including the ones buried in vendored scaffolding tools or Docker layers that traditional scanners miss — giving you a single inventory to work from. And when it's time to fix, Safeguard can open auto-fix PRs that bump the dependency version and add the allow-list guardrails around your render calls, turning a multi-team remediation effort into a review-and-merge action.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.