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:
- Accepts user input (HTTP parameters, request body, external API responses), and
- 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
outputFunctionNameis a code-generation sink, not a data field. In EJS 2.x, this option is interpolated verbatim into JavaScript source beforeFunction()evaluation — making it a direct RCE vector if user-controlled.- Upgrading
package.jsonalone is not enough. Thepackage-lock.jsonmust also be updated to guarantee the safe version is installed in all environments, including CI/CD pipelines runningnpm ci. - SSTI bypasses output escaping entirely. Developers who rely on EJS's
<%- %>vs<%= %>escaping to prevent injection are not protected againstoutputFunctionName-based attacks, which occur at compile time. - The jump from
2.7.4to6.0.1also removedhasInstallScript: 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
outputFunctionNameEJS 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/ejsversion2.7.4, whereoutputFunctionNameis concatenated into a JavaScript source string passed to theFunction()constructor. - Missing control: No validation or allowlisting of the
outputFunctionNamevalue 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.4to6.0.1in bothpackage.jsonandpackage-lock.json, resolving to a version that validatesoutputFunctionNameagainst 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.