How API Key Exposure and ReDoS Happens in Node.js and How to Fix It
Introduction
In the roll/openai.js module—a production OpenAI integration handling chat completions, image generation, and error messaging—we discovered two distinct but equally dangerous vulnerabilities. The first, at line 612 in the addApiKey() method, loads API secrets from process.env without verifying the execution environment, meaning those keys could end up in a client-side JavaScript bundle visible to any user with browser developer tools. The second, at line 907 in generateErrorMessage(), constructs a regular expression dynamically from user-controlled input, creating a Regular Expression Denial of Service (ReDoS) vector that could crash the Node.js process.
These aren't theoretical risks. The module is part of a web service where request handlers are directly reachable by remote attackers, and the brace-expansion dependency (CVE-2026-14257) in yarn.lock compounds the denial-of-service surface by allowing unbounded expansion that triggers out-of-memory crashes.
The Vulnerability Explained
Vulnerability 1: API Key Exposure via Client-Side Bundling
The addApiKey() method iterates over environment variables to populate an internal this.apiKeys array:
addApiKey() {
this.apiKeys = [];
let base = 0;
for (let index = 1; index < 100; index++) {
// Loads OPENAI_SECRET_0, OPENAI_SECRET_1, etc.
// ...
}
}
If this module is ever included in a client-side bundle (via Webpack, Vite, or similar), modern bundlers may resolve process.env.OPENAI_SECRET_0 at build time and inline the actual key value into the output JavaScript. An attacker would simply:
- Open browser developer tools on the web application
- Search the bundled JavaScript for
sk-(OpenAI key prefix) - Extract the key from the
this.apiKeysarray initialization - Use the stolen key to make unlimited OpenAI API calls at the victim's expense
This is especially dangerous because OpenAI API keys grant access to expensive compute resources and potentially sensitive training data.
Vulnerability 2: ReDoS in generateErrorMessage()
The original code constructed a regular expression from user-controlled input:
const commandType = inputText.match(/^\.(ai|ait|aimage)[mh]?/i)?.[0] || '.ai';
const cleanInput = inputText.replace(new RegExp(`^${commandType}`, 'i'), '');
While commandType is constrained by the first regex match, the use of new RegExp() with string interpolation is a dangerous pattern. If commandType contained regex metacharacters (e.g., through future code changes or edge cases), an attacker could craft inputText that causes catastrophic backtracking, freezing the event loop and crashing the process.
Combined with CVE-2026-14257 in brace-expansion (which allows unbounded memory allocation via crafted expansion patterns), the denial-of-service attack surface was significant.
The Fix
Two surgical changes were applied to roll/openai.js:
Fix 1: Environment Guard in addApiKey() (Line 613)
Before:
addApiKey() {
this.apiKeys = [];
let base = 0;
// ... loads keys from process.env
}
After:
addApiKey() {
if (typeof window !== 'undefined') return;
this.apiKeys = [];
let base = 0;
// ... loads keys from process.env
}
The typeof window !== 'undefined' check is the standard idiom for detecting a browser environment. If this code somehow ends up in a client-side bundle, it will short-circuit immediately, never populating this.apiKeys with sensitive values. This is a defense-in-depth measure—the module shouldn't be bundled client-side at all, but this guard ensures secrets stay safe even if it is.
Fix 2: Replace Dynamic RegExp with String.slice() (Line 908)
Before:
const cleanInput = inputText.replace(new RegExp(`^${commandType}`, 'i'), '');
After:
const cleanInput = inputText.slice(commandType.length);
This eliminates the regular expression entirely. Since commandType is always a prefix of inputText (it was extracted from inputText via the preceding match), String.prototype.slice() achieves the same result with zero regex overhead and zero ReDoS risk. It's faster, simpler, and immune to metacharacter injection.
Prevention & Best Practices
-
Never trust bundler boundaries: Even if you "know" a module is server-only, add runtime guards before accessing secrets. Bundler configurations change, and a single misconfigured import can expose everything.
-
Avoid dynamic RegExp with any user-influenced data: Use string methods (
slice,startsWith,indexOf) whenever possible. If you must use RegExp, escape inputs with a utility likeescapeRegExp(). -
Audit
yarn.lock/package-lock.jsonfor CVEs: Thebrace-expansionvulnerability (CVE-2026-14257) in the dependency tree compounds DoS risks. Usenpm auditoryarn auditregularly. -
Mark server-only modules explicitly: Use package.json
"sideEffects"fields, or framework-specific conventions like Next.jsserver-onlyimports, to prevent accidental client bundling. -
Principle of least privilege for API keys: Even if keys aren't exposed, rotate them regularly and use scoped keys with spending limits where providers support them.
Key Takeaways
- The
addApiKey()method inroll/openai.jshad no environment check, meaning any bundler that resolvedprocess.envat build time would inline real OpenAI API keys into client-facing JavaScript. - A single
typeof window !== 'undefined'guard provides defense-in-depth against secret exposure, even when architectural boundaries fail. - **
new RegExp(\^${commandType}`, 'i')ingenerateErrorMessage()was unnecessary**—String.slice()` does the same job without any regex attack surface. - CVE-2026-14257 in
brace-expansionreminds us that DoS vulnerabilities in transitive dependencies amplify application-level ReDoS risks. - The safest regex is no regex at all—when you already know the exact prefix to remove, string operations are both safer and faster.
How Orbis AppSec Detected This
- Source:
process.env.OPENAI_SECRET_*environment variables read inroll/openai.js:612within theaddApiKey()method - Sink:
this.apiKeysarray populated with raw secret values, accessible in any execution context including client-side bundles - Missing control: No runtime environment check (
typeof window) before loading secrets; nonew RegExp()input sanitization ingenerateErrorMessage() - CWE: CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-1333 (Inefficient Regular Expression Complexity)
- Fix: Added browser environment guard to prevent key loading in client contexts and replaced dynamic RegExp with
String.slice()
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
This case demonstrates how two seemingly minor code patterns—an unguarded process.env read and a dynamic new RegExp()—can create critical security exposure in a production web service. The fixes are minimal (a 1-line environment check and a method swap from replace(RegExp) to slice()), but the security improvement is substantial: API keys can no longer leak to browsers, and the error message handler can no longer be weaponized for denial of service.
When writing Node.js modules that handle secrets, always assume your code might end up somewhere unexpected. Guard accordingly.