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 infinite timeoutConfigurationManager.AppSettings.Set(key, value)— mutates .NET Framework settings
ASP.NET Core Configuration Patterns:
IOptions<T>: Singleton snapshot bound at startup — immutable and preferredIOptionsSnapshot<T>: Per-request snapshot re-read from config files each requestIOptionsMonitor<T>: Live-reload from files only — not from HTTP requestsLoggingLevelSwitch: 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 ConnectionStrings__DefaultConnection, ASPNETCORE_ENVIRONMENT, etc.
Environment.SetEnvironmentVariable(model.Key, model.Value);
return Ok("Updated");
}
// Attack example:
// POST /config/env {"key": "ASPNETCORE_ENVIRONMENT", "value": "Development"}
// Result: Developer exception page enabled — full stack traces exposed to users
Why this is vulnerable: Environment.SetEnvironmentVariable() modifies process-level environment variables that ASP.NET Core reads for security-critical decisions. ASPNETCORE_ENVIRONMENT gates the developer exception page; ConnectionStrings__* variables determine database access; JWT key variables enable token verification.
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 sets -1 (infinite) or int.MaxValue
var content = await client.GetStringAsync(url);
return Ok(content);
}
// Attack example:
// POST /fetch?url=https://slow.example.com&timeoutSeconds=-1
// Result: TimeSpan.FromSeconds(-1) = InfiniteTimeout — thread blocks forever
// Flood with many such requests → thread pool exhaustion → denial of service
Why this is vulnerable: HttpClient.Timeout accepts TimeSpan.FromSeconds(-1) as infinite timeout. An attacker who can set this value causes threads to block indefinitely on connections to slow or unresponsive servers, exhausting the thread pool and denying service to all users.
Secure Patterns
IOptions 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> snapshots configuration at application startup, not at request time. 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 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 at all, 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
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 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, making it impossible for application code to accidentally override the destination.