CWE-15: External Control of System or Configuration Setting - JavaScript
Overview
External control of configuration in Node.js applications occurs when HTTP request parameters, body data, query strings, or headers are used to directly modify process.env, configuration management objects, logger settings, or application-level config stores at runtime. Attackers can exploit this to disable security middleware, redirect API calls to attacker-controlled servers, silence audit logging, or expose debug information.
Common Node.js Configuration Injection Scenarios:
process.env[req.body.key] = req.body.value- modifies process environment from a requestsetPath(appConfig, req.body.key, req.body.value)- a dotted-path setter over the config objectlogger.level = req.body.level- winston/pino/bunyan log level control from request- Accepting arbitrary Express middleware configuration from request parameters
Node.js Configuration Libraries:
- dotenv: Loads
.envfiles intoprocess.envat startup - node-config: Hierarchical config management (
configpackage) - convict: Schema-based configuration with format validation
- winston/pino/bunyan: Logging libraries with configurable levels
Primary Defence: Load all configuration at startup from environment variables (via dotenv) or config files - then freeze or seal the resulting object. Never expose endpoints that allow modification of process.env or config objects. Any runtime configuration endpoint must require admin authorization and constrain values to an explicit allowlist.
Common Vulnerable Patterns
process.env Modified from Request
// VULNERABLE - Environment variable set from HTTP request
const express = require('express');
const app = express();
app.use(express.json());
app.post('/config/env', (req, res) => {
const { key, value } = req.body;
process.env[key] = value; // Attacker sets NODE_TLS_REJECT_UNAUTHORIZED, DISABLE_AUTH, etc.
res.send('Updated');
});
// Attack example:
// POST /config/env {"key": "NODE_TLS_REJECT_UNAUTHORIZED", "value": "0"}
// Result: certificate verification is off for every subsequent outbound HTTPS request
// in the process - a self-signed MITM certificate is now accepted silently
// POST /config/env {"key": "DISABLE_AUTH", "value": "true"}
// Result: Authentication checks bypassed if the app reads this flag
Why this is vulnerable: process.env is a live, process-wide object, and any code that reads a
variable after the write sees the new value. Node reads
NODE_TLS_REJECT_UNAUTHORIZED on each TLS connection, so setting it to 0 from one request turns
off certificate verification for every outbound HTTPS call the process makes from then on. Feature
flags an application checks inside a handler (if (process.env.DISABLE_AUTH === 'true')) are
affected on the very next request.
Which variable is chosen matters, and a triage note claiming more than this will not hold up. Values
consumed once at startup do not change a running process: NODE_ENV is copied into the Express
application at creation (app.set('env', process.env.NODE_ENV || 'development')), so overwriting it
later leaves app.get('env') on its original value and does not switch on stack-trace error
pages. NODE_OPTIONS and NODE_EXTRA_CA_CERTS are read by Node as it starts, so overwriting them
does not reconfigure the running process either - and because process.env dies with the process,
there is no later start that inherits them.
They still matter, for a different reason: a child process inherits the modified environment. With
NODE_OPTIONS set to --require /tmp/evil.js, the next child_process spawn of node runs the
attacker's file before the child's own entry point. So the honest impact of an arbitrary
process.env write is whatever the process reads at use time, plus whatever it later spawns - not
its own configuration on some future boot.
Arbitrary Config Path Set from Request
// VULNERABLE - Any config path overwritable from request body
const express = require('express');
const app = express();
app.use(express.json());
const settings = require('./settings'); // plain nested object loaded at startup
function setPath(obj, path, value) {
const parts = path.split('.');
const last = parts.pop();
const target = parts.reduce((o, k) => (o[k] ??= {}), obj);
target[last] = value;
}
app.post('/admin/config', (req, res) => {
const { key, value } = req.body;
setPath(settings, key, value); // key is a dotted path straight from the request
res.json({ status: 'updated' });
});
// Attack example:
// POST /admin/config {"key": "security.rateLimiting.enabled", "value": false}
// Result: Rate limiting disabled - brute-force attacks now possible
// POST /admin/config {"key": "__proto__.isAdmin", "value": true}
// Result: prototype pollution - every plain object in the process now reports isAdmin
Why this is vulnerable: A dotted-path setter over the application's configuration object is a
common convenience helper, and it turns one endpoint into write access to every setting the
application has - rate limiting, authentication requirements, TLS options. Because the traversal
creates missing levels as it walks, it also reaches keys that are not configuration at all: a key
of __proto__.isAdmin or constructor.prototype.isAdmin writes onto Object.prototype, so the
blast radius is not bounded by what the config file happens to contain.
If the application uses the config package rather than a hand-rolled store, check the version
before recording severity. From v5 the returned object refuses writes once a value has been read
through config.get(), throwing Can not update runtime configuration property, and the
config.util.extendDeep and config.util.setPath helpers that older guidance reaches for no longer
exist on it. A write that lands before the first get() of that subtree still succeeds, so this
narrows the window rather than closing it.
Log Level Set from Request Body (Winston)
// VULNERABLE - Winston log level controlled by client
const winston = require('winston');
const logger = winston.createLogger({ level: 'info', /* ... */ });
app.post('/debug/log-level', (req, res) => {
const { level } = req.body;
logger.level = level; // Attacker sets 'silly' or 'debug'
res.send(`Log level set to ${level}`);
});
// Attack example:
// POST /debug/log-level {"level": "debug"}
// Result: Passwords, tokens, session IDs written to log files
// POST /debug/log-level {"level": "error"}
// Result: Access events and auth failures no longer logged
Why this is vulnerable: logger.level is a plain assignable property - Winston does not check
the name against its level table on the way in. Setting debug or silly causes all internal data
(HTTP headers, request bodies, database queries with their parameters) to be written to every
transport. Setting error drops the access and authentication-failure records. Setting a name that
is not a level at all is the worst of the three: logger.isLevelEnabled() looks the string up in
logger.levels, gets undefined, and every comparison fails, so a single request that posts
{"level": "x"} silently stops the process logging anything.
CORS Config Mutated at Runtime
// VULNERABLE - CORS configuration overwritten from request
const cors = require('cors');
let corsOptions = { origin: 'https://trusted.com' };
app.use(cors(corsOptions));
app.post('/admin/cors', (req, res) => {
corsOptions.origin = req.body.origin; // Attacker sets '*' or 'https://evil.com'
res.send('CORS updated');
});
// Attack example:
// POST /admin/cors {"origin": "*"}
// Result: CORS policy now allows any origin - cross-site requests enabled
Why this is vulnerable: CORS and other security middleware read their configuration values at request time. Mutating corsOptions while the application is running means the very next request is checked against the attacker-supplied origin instead of https://trusted.com.
Secure Patterns
Immutable Config Object at Startup (PREFERRED)
// SECURE - Configuration frozen at startup, never mutated
// config.js - loaded once during application startup
require('dotenv').config();
const config = Object.freeze({
nodeEnv: process.env.NODE_ENV || 'production',
logLevel: process.env.LOG_LEVEL || 'info',
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS, 10) || 1800000,
dbUrl: process.env.DATABASE_URL,
// No setter methods - configuration is read-only
});
module.exports = config;
// Attempting to modify after startup:
// config.logLevel = 'debug';
// TypeError: Cannot assign to read only property 'logLevel'
Why this works: Object.freeze() makes the config object read-only at the JavaScript level - any attempt to assign to a property throws a TypeError in strict mode and silently fails otherwise. Configuration is loaded only from environment variables and .env files at startup, so there is no code path from a request to a config mutation.
CORS Origins Fixed at Startup
// SECURE - the origin allowlist is read once at startup and never reassigned
const cors = require('cors');
// One place, one time. Object.freeze stops a later assignment, and no route writes to it.
const ALLOWED_ORIGINS = Object.freeze(
(process.env.CORS_ALLOWED_ORIGINS || 'https://trusted.com')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
);
app.use(cors({
// A function rather than a string: cors calls it per request, and it answers
// from the frozen list instead of from anything the request carries.
origin(origin, callback) {
// No Origin header (same-origin, curl, server-to-server) needs no CORS headers.
if (!origin) return callback(null, false);
callback(null, ALLOWED_ORIGINS.includes(origin));
},
credentials: true // a specific origin is required with credentials - '*' is rejected
}));
// There is no /admin/cors route. Changing who may call this API is a deploy:
// edit CORS_ALLOWED_ORIGINS and restart.
Why this works: The allowlist is built once from the environment and frozen, so the
mutation the vulnerable pattern relies on throws instead of taking effect - ALLOWED_ORIGINS.push(...)
raises TypeError: Cannot add property 1, object is not extensible. The origin callback
decides per request, but it decides by membership: a request from https://evil.com gets no
Access-Control-Allow-Origin header at all rather than a permissive one, so the browser blocks
the read. Measured on Express 5 with cors 2.8: https://trusted.com comes back with
Access-Control-Allow-Origin: https://trusted.com and Access-Control-Allow-Credentials: true,
while https://evil.com and a request with no Origin get neither header.
Reflecting req.headers.origin back would pass the same test and be worthless - the check has to
be membership of a list the request cannot reach. credentials: true is also what rules out the
origin: '*' the attack sets: the browser refuses a wildcard origin on a credentialed request, so
the wildcard is not a weaker allowlist, it is a broken one.
Allowlist-Validated Runtime Log Level Change
// SECURE - Admin-only endpoint with explicit allowlist
const ALLOWED_LOG_LEVELS = new Set(['info', 'warn', 'error']);
function requireAdmin(req, res, next) {
if (!req.user || !req.user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
app.post('/admin/log-level', requireAdmin, (req, res) => {
const level = (req.body.level || '').toLowerCase();
if (!ALLOWED_LOG_LEVELS.has(level)) {
return res.status(400).json({
error: `Invalid level. Allowed: ${[...ALLOWED_LOG_LEVELS].join(', ')}`
});
}
logger.level = level;
logger.info('Log level changed to %s by admin user %s', level, req.user.id);
res.json({ status: 'updated', level });
});
Why this works: The ALLOWED_LOG_LEVELS Set acts as a server-side allowlist - strings like 'debug', 'silly', or 'verbose' are rejected before touching the logger. The requireAdmin middleware gates the endpoint so only authenticated admin users can reach the handler. All changes are audit-logged with the user's identity.
Zod Schema Validation for Config Endpoint
// SECURE - zod schema restricts both key names and value types
const { z } = require('zod');
// Each key carries its own value schema - the pair is validated, not the two halves separately
const ConfigUpdateSchema = z.discriminatedUnion('key', [
z.object({
key: z.literal('session.timeout'),
value: z.number().int().min(60).max(86400) // seconds
}),
z.object({
key: z.literal('feature.beta_ui'),
value: z.boolean()
}),
z.object({
key: z.literal('rate_limit.requests_per_minute'),
value: z.number().int().min(1).max(10000)
})
]);
app.post('/admin/config', requireAdmin, (req, res) => {
const result = ConfigUpdateSchema.safeParse(req.body);
if (!result.success) {
// .issues, not .errors - the v3 alias was dropped in Zod 4
return res.status(400).json({ error: result.error.issues });
}
const { key, value } = result.data;
configService.set(key, value);
logger.info('Config %s set to %s by %s', key, value, req.user.id);
res.json({ status: 'updated' });
});
Why this works: z.discriminatedUnion('key', ...) restricts which configuration keys can be
changed to an explicit, code-reviewed list, and - the part that matters - binds each key to the
value schema that belongs to it. Any key not in the list, including every security-critical setting,
is rejected before reaching configService.set().
Pairing the two is what makes this an allowlist rather than two overlapping ones. The obvious
spelling, a z.enum of keys beside a z.union of value types, validates each half in isolation:
{"key": "session.timeout", "value": "not-a-timeout"} passes, because the key is on the list and
the value is a permitted type, just not a permitted type for that key. So does
{"key": "feature.beta_ui", "value": 9999} and
{"key": "rate_limit.requests_per_minute", "value": false}. Validation reports success and
configService.set() is left to interpret a value it was never meant to receive - which is where
NaN timeouts and always-on feature flags come from. The discriminated union rejects all three.
Convict Schema-Based Validated Config
// SECURE - Convict enforces a schema with allowlists at load time
const convict = require('convict');
// format: Number does not reject non-numeric input - supply a bounded check instead
function intRange(min, max) {
return (val) => {
if (!Number.isInteger(val) || val < min || val > max) {
throw new Error(`must be an integer between ${min} and ${max}`);
}
};
}
const config = convict({
logLevel: {
doc: 'Application log level',
format: ['info', 'warn', 'error'], // Built-in allowlist
default: 'info',
env: 'LOG_LEVEL'
},
sessionTimeout: {
doc: 'Session timeout in seconds',
format: intRange(60, 86400),
default: 1800,
env: 'SESSION_TIMEOUT'
}
});
config.validate({ allowed: 'strict' }); // Throws if any value fails its format
const frozen = Object.freeze(config.getProperties());
module.exports = frozen;
Why this works: Convict's format array acts as a schema-level allowlist validated at startup.
If LOG_LEVEL is set to debug or verbose, config.validate() throws and the application refuses
to start. The resulting object is frozen, so no HTTP request can mutate it afterwards.
Numeric fields need more than format: Number, which is the trap in the obvious version of this
schema. Convict coerces the environment value with Number() and does not reject the result, so
SESSION_TIMEOUT=abc passes validate({ allowed: 'strict' }) and yields NaN - a value that is
falsy in a comparison and silently disables whatever it configures. format: 'int' and
format: 'nat' do reject it (nat also rejects negatives) but neither has an upper bound, so a
function format is what expresses "an integer in this range" and fails startup on abc, -5,
1800.5, an empty string, and anything outside the bounds.
Testing
Verify the fix by testing:
- Allowlist bypass: Submit log levels like
debug,silly, orverbose- expect 400 rejection - Unknown key injection: Attempt to set config keys like
jwt.secretorsecurity.disabled- expect 400 process.envmutation: Verify no endpoint accepts arbitrary environment variable names/values- Authorization bypass: Call admin config endpoints without admin credentials - expect 401/403
- Object mutation after freeze: Confirm
config.someKey = 'value'throws or silently fails
Untrusted Configuration Sources
Configuration is also externally controlled when the application loads it from a location that untrusted input controls.
Config File Loaded from User-Supplied Path (Vulnerable)
// VULNERABLE - JSON config file path comes from request body
const fs = require('fs');
const express = require('express');
const app = express();
app.post('/admin/load-config', (req, res) => {
const filePath = req.body.path;
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Attack: path = "../../config/production.json" or an attacker-uploaded file
applyConfig(data);
res.send('Loaded');
});
// Attack example:
// POST /admin/load-config {"path": "../../config/production.json"}
// Result: production settings - API keys, database URLs - re-applied or replaced
// POST /admin/load-config {"path": "/etc/passwd"}
// Result: SyntaxError whose message quotes the first bytes of the file; if the
// handler returns err.message, that confirms the file exists and leaks its start
Why this is vulnerable: fs.readFileSync with a user-supplied path reads any file the process
has permission to access, and ../ sequences traverse out of any intended directory. JSON.parse
narrows what an attacker gets from that read but does not stop it: a file that is not valid JSON
throws SyntaxError, and the message quotes the first bytes of the offending text, so an error
handler that echoes err.message turns the endpoint into a file-existence oracle that also returns
the start of whatever it found. Files that are valid JSON - the
application's own config, a package manifest, a credentials file written by a cloud SDK - are read
and applied in full.
Config File Loaded from User-Supplied Path (Secure)
// SECURE - Only a fixed set of filenames are accepted; path is never from user input
const fs = require('fs');
const path = require('path');
const { z } = require('zod');
const CONFIG_DIR = path.resolve('/var/app/configs');
const ALLOWED_FILENAMES = new Set(['feature-flags.json', 'rate-limits.json']);
app.post('/admin/load-config', requireAdmin, (req, res) => {
const filename = req.body.filename;
if (!ALLOWED_FILENAMES.has(filename)) {
return res.status(400).json({ error: 'Unknown config file' });
}
// Resolve within trusted directory and verify no traversal
const resolved = path.resolve(CONFIG_DIR, filename);
if (!resolved.startsWith(CONFIG_DIR + path.sep)) {
return res.status(400).json({ error: 'Invalid path' });
}
let data;
try {
data = JSON.parse(fs.readFileSync(resolved, 'utf8'));
} catch (e) {
return res.status(400).json({ error: 'Invalid JSON' });
}
configService.applyAllowlisted(data);
logger.info('Config file %s loaded by admin %s', filename, req.user.id);
res.json({ status: 'loaded' });
});
Why this works: ALLOWED_FILENAMES is an explicit set - any name not in it is rejected before a filesystem path is constructed. path.resolve() + startsWith(CONFIG_DIR + path.sep) is a defence-in-depth check that blocks ../ sequences entirely. JSON.parse is safe for data loading (unlike eval or require() which execute code).
Remote Config URL Fetched at Request Time (Vulnerable)
// VULNERABLE - Application fetches config from user-supplied URL
const axios = require('axios');
app.post('/admin/remote-config', async (req, res) => {
const configUrl = req.body.url;
// Attack: url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
const response = await axios.get(configUrl);
applyConfig(response.data);
res.send('Applied');
});
// Attack example:
// POST /admin/remote-config {"url": "http://169.254.169.254/latest/meta-data/"}
// Result: AWS instance metadata fetched - IAM credentials stolen and applied as config
Why this is vulnerable: Fetching a URL derived from user input is an SSRF vulnerability. In cloud environments, the instance metadata endpoint exposes IAM credentials. Internal services (databases, Kubernetes API, internal admin panels) are also reachable from the application host.
Remote Config URL Fetched at Request Time (Secure)
// SECURE - Config source URL is a constant; user cannot influence which endpoint is called
const axios = require('axios');
const INTERNAL_CONFIG_URL = 'https://config.internal.example.com/api/v1/app-config';
app.post('/admin/refresh-config', requireAdmin, async (req, res) => {
// URL is NOT derived from any request parameter
const response = await axios.get(INTERNAL_CONFIG_URL, {
headers: { Authorization: `Bearer ${internalTokenProvider.get()}` }
});
configService.applyFromObject(response.data);
logger.info('Config refreshed from internal service by admin %s', req.user.id);
res.json({ status: 'refreshed' });
});
Why this works: The config endpoint URL is a module-level constant - there is no code path from an HTTP request parameter to the URL used in the outbound axios.get() call, so nothing an attacker puts in the request body can redirect the fetch to another host.
require() with User-Supplied Path (Vulnerable)
// VULNERABLE - require() with user-controlled path executes arbitrary code
app.post('/admin/plugin-config', (req, res) => {
const pluginPath = req.body.path;
const plugin = require(pluginPath); // NEVER do this
// Attack: path = "/tmp/malicious" (attacker-uploaded file)
applyConfig(plugin.config);
res.send('Loaded');
});
// Attack example:
// Attacker uploads malicious.js to /tmp/ via a file upload endpoint
// POST /admin/plugin-config {"path": "/tmp/malicious"}
// Result: malicious.js is executed - arbitrary code execution
Why this is vulnerable: require() executes the loaded module. If an attacker controls the path
and can write a file anywhere the application can read - an upload directory, a world-writable temp
directory, a cache the application populates from user input - they achieve remote code execution.
The path does not need a .js extension: with no recognised extension Node compiles the file as
JavaScript anyway, so an "image" or "log" an attacker planted earlier is enough. require() also
resolves relative paths and node_modules lookups, so ../../uploads/avatar reaches outside any
intended plugin directory.