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
  • config.set(req.query.key, req.query.value) — arbitrary node-config mutation
  • 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_ENV, DB_URL, JWT_SECRET, etc.
    res.send('Updated');
});

// Attack example:
// POST /config/env {"key": "NODE_ENV", "value": "development"}
// Result: Developer error pages enabled — full stack traces with locals exposed
// 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 global object shared across all modules. Overwriting values immediately affects every part of the application that reads those variables, including NODE_ENV, database connection strings, JWT secrets, and feature flags.

Arbitrary node-config Key Set from Request

// VULNERABLE - Any config path overwritable from request body
const config = require('config');
const express = require('express');
const app = express();

app.post('/admin/config', (req, res) => {
    const { key, value } = req.body;
    // node-config is ordinarily read-only but can be bypassed
    config.util.extendDeep(config, { [key]: value });
    res.json({ status: 'updated' });
});

// Attack example:
// POST /admin/config {"key": "security.rateLimiting.enabled", "value": false}
// Result: Rate limiting disabled — brute-force attacks now possible

Why this is vulnerable: Accepting arbitrary config key paths and overwriting them allows attackers to target any configuration value, including those governing security behaviour like rate limiting, authentication requirements, and TLS settings.

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: Winston accepts arbitrary string level names. Setting debug or silly causes all internal data (including HTTP headers, request bodies, database queries with parameters) to be written to log destinations. Setting a high level silences the audit trail entirely.

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 uses the attacker-controlled value, potentially opening the application to cross-origin attacks.

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, never from HTTP requests. There is simply no code path from a request to a config mutation.

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');

const ConfigUpdateSchema = z.object({
    key: z.enum([
        'session.timeout',
        'feature.beta_ui',
        'rate_limit.requests_per_minute'
    ]),
    value: z.union([
        z.string().max(100),
        z.number().int().min(0).max(10000),
        z.boolean()
    ])
});

app.post('/admin/config', requireAdmin, (req, res) => {
    const result = ConfigUpdateSchema.safeParse(req.body);
    if (!result.success) {
        return res.status(400).json({ error: result.error.errors });
    }

    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: The z.enum([...]) for key restricts which configuration keys can be changed to an explicit, code-reviewed list. Any key not in this list — including all security-critical settings — is rejected by Zod before reaching configService.set(). The value union schema further constrains input type and range, preventing injection through type confusion.

Convict Schema-Based Validated Config

// SECURE - Convict enforces a schema with allowlists at load time
const convict = require('convict');

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: Number,
        default: 1800,
        env: 'SESSION_TIMEOUT'
    }
});

config.validate({ allowed: 'strict' }); // Throws if any value is invalid

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 an invalid value (e.g., debug, verbose), config.validate() throws and the application refuses to start. The resulting object is frozen, preventing any runtime mutation from HTTP requests.

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

A related attack vector occurs when the application loads configuration 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 = "../../etc/passwd" or "/proc/self/environ"
    applyConfig(data);
    res.send('Loaded');
});

// Attack example:
// POST /admin/load-config {"path": "/proc/self/environ"}
// Result: Process environment (including secrets) parsed as JSON config

Why this is vulnerable: fs.readFileSync with a user-supplied path reads any file the process has permission to access. ../ sequences traverse to arbitrary directories. /proc/self/environ on Linux exposes all environment variables including secrets the app loaded at startup.

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. An attacker cannot redirect the fetch to an arbitrary host regardless of what they send in the request body.

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 to a location the application can read (e.g., via a file upload feature), they achieve remote code execution. Even without writing files, require('/proc/self/environ') on some Node.js versions leaks environment variables.

Additional Resources