Skip to content

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 request
  • setPath(appConfig, req.body.key, req.body.value) - a dotted-path setter over the config object
  • logger.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 .env files into process.env at startup
  • node-config: Hierarchical config management (config package)
  • 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, or verbose - expect 400 rejection
  • Unknown key injection: Attempt to set config keys like jwt.secret or security.disabled - expect 400
  • process.env mutation: 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.

Additional Resources