Skip to main content
interlace
Plugin: node-securityRules

no-env-injection

Detects writes to process.env under a key the caller controls, which can overwrite PATH, NODE_OPTIONS or LD_PRELOAD and change how later processes execute

CWE: CWE-99 (Improper Control of Resource Identifiers) OWASP: A03:2021 – Injection

Quick Summary

AspectDetails
CWE ReferenceCWE-99
SeverityHigh
Auto-Fix❌ None — the safe form needs an allowlist you have to write
Detectiontaint (key expression)
Recommendederror

Rule Details

const { key, value } = req.body;
process.env[key] = value;

Review usually lands on the value here — "is it a string, is it validated?" — and misses that the caller also picked the name. The name is the vulnerability:

Variable the caller can nameWhat happens next
PATHevery later spawn/exec resolves its binary somewhere you didn't choose
NODE_OPTIONS--require ./payload.js loads into every child Node process
LD_PRELOADa shared object is injected into every child process

None of these need a second request to pay off. The next child process the app starts executes attacker-chosen code, with the app's privileges.

This rule therefore judges the key, not the value.

Why a "the key is not a literal" check would be wrong

The documented fix for this defect is a lookup in a closed allowlist:

const ALLOWED = { locale: 'APP_LOCALE', theme: 'APP_THEME' };
const target = ALLOWED[req.body.setting];
if (!target) return res.status(400).end();
process.env[target] = String(req.body.value);

target is not a literal — but it can only ever hold one of the strings written above it. A rule that reported every non-literal key would report the fix, which is how rules get switched off. So the rule traces the key back through its bindings and asks where it came from: a member chain rooted at the request reports; a lookup rooted at a local object does not.

How this differs from the neighbouring rules

RuleCWEShape
no-dynamic-command-stringCWE-77the command string handed to a shell
no-shell-injectionCWE-78exec() with a concatenated command line
detect-child-processCWE-78broad net over child_process usage
no-env-injectionCWE-99the environment those calls inherit — which none of the above look at

Examples

❌ Incorrect

// The caller names the variable
function setConfig(req, res) {
  const { key, value } = req.body;
  process.env[key] = value;
}

// Direct, without a binding in between
process.env[req.body.key] = req.body.value;
process.env[request.query.name] = '1';
process.env[ctx.request.body.name] = '1';
process.env[event.queryStringParameters.k] = '1';

// Aliased on the way
const k = req.body.key;
process.env[k] = '1';

// Bulk: every name and value at once
Object.assign(process.env, req.body);

✅ Correct

// A closed allowlist decides which variable is written
const ALLOWED = { locale: 'APP_LOCALE', theme: 'APP_THEME' };
const target = ALLOWED[req.body.setting];
if (!target) return res.status(400).end();
process.env[target] = String(req.body.value);

// The source names the variable
process.env.NODE_ENV = 'production';
process.env.APP_LOCALE = req.body.locale;

// Keys you copy yourself
Object.assign(process.env, { NODE_ENV: 'production' });

Options

OptionTypeDefaultDescription
extraRequestRootsstring[][]Extra identifiers to treat as roots of request-controlled data. Added to the built-in roots, not a replacement for them.

The default roots are req, request, ctx and event.

{
  "rules": {
    "node-security/no-env-injection": [
      "error",
      { "extraRequestRoots": ["payload"] }
    ]
  }
}

When Not To Use It

A CLI or build script whose only "caller" is the developer at the keyboard has no attacker in the loop. Prefer an ESLint config override scoped to those directories over disabling the rule repo-wide.

Known False Negatives

The following patterns are not detected due to static analysis limitations:

The Environment Object Is Aliased First

Why: the rule matches process.env written out in full.

// ❌ NOT DETECTED
const env = process.env;
env[req.body.key] = req.body.value;

The Key Crosses A Function Boundary

Why: the trace follows bindings, not call graphs.

// ❌ NOT DETECTED
function apply(name, value) { process.env[name] = value; }
apply(req.body.key, req.body.value);

The Request Is Bound To An Unrecognised Name

Why: request roots are matched by name.

// ❌ NOT DETECTED — until added to extraRequestRoots
process.env[payload.key] = payload.value;

More Than Three Binding Hops

Why: the trace budget stops at three hops to keep the rule linear.

// ❌ NOT DETECTED
const a = req.body.key; const b = a; const c = b; const d = c;
process.env[d] = '1';

Further Reading

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.