DEV Community

Giovanni Leopoldo Rozza
Giovanni Leopoldo Rozza

Posted on

What Spring Boot actually does with your configuration

I maintain spring-config-guard, a static-analysis CLI that reads application.yml/.properties files and reports security misconfigurations: exposed Actuator endpoints, hardcoded credentials, unencrypted Kafka, and so on. A linter like this is only as good as its model of how Spring Boot resolves configuration. If the model is wrong, it reports risks that don't exist, or misses ones that do.

So I started checking behavior against a running Spring Boot 4.1.1 app instead of relying on what the documentation implies. Several results surprised me, and some changed how the linter works.

Throughout the article I mark where each claim comes from: (docs) when the Spring documentation states it, (observed) when I saw it in Spring Boot 4.1.1 with a reproducible test, and (source) when I read it in the implementation.

The method: /actuator/configprops next to /actuator/env

My first instinct was to compare the linter's view with /actuator/env. That works for simple overrides, but not for most interesting cases.

/actuator/env lists each property source separately, with its raw keys. If application.yml defines a list and application-prod.yml overrides it, /env shows both sources, each with its own keys. It doesn't tell you which one wins: merging and binding happen later, in Spring's Binder.

/actuator/configprops shows the values after binding into @ConfigurationProperties classes, with precedence and merging applied. For properties bound that way, it is a much better picture of what the application gets than /env. (It doesn't cover values read through @Value or directly from the Environment.) So for each binding question below, I bound a property into a small record in the test app and read what configprops reported:

@ConfigurationProperties("app")
public record BenchmarkProperties(Map<String, String> bracketMap, ListCases lists) { ... }
Enter fullscreen mode Exit fullscreen mode

1. Actuator: access and exposure are two different things

This one matters most for security. An endpoint is available over HTTP only when its access is permitted and it is exposed over HTTP (docs). management.endpoints.web.exposure.include only answers the second question.

I started the same app once per configuration, always with exposure.include=*, and recorded which sensitive endpoints /actuator linked to (observed):

With exposure.include=* and... Sensitive endpoints available over HTTP
nothing else env, threaddump, configprops, beans, loggers
exposure.exclude=env,heapdump the same, without env
endpoints.access.default=none none
endpoints.access.max-permitted=none none
management.server.port=-1 none (HTTP endpoints disabled)
endpoints.access.default=unrestricted the default set, plus heapdump and shutdown
endpoints.access.default=read-only the default set, plus heapdump (not shutdown)
access.default=none and env.access=unrestricted env only

What to take from it:

  • exclude wins over include (docs). include=* with exclude=env,heapdump is a reasonable pattern.
  • heapdump and shutdown have restricted access by default (docs), so include=* alone doesn't expose them. A global access.default=unrestricted lifts that restriction, and the wildcard then exposes both (observed). heapdump is a memory dump of your application, secrets included: a permissive global default "to make things work" reaches further than the endpoints you had in mind.
  • read-only is about operations, not endpoints (docs). With access.default=read-only, heapdump (a read operation) is available, but shutdown (write only) is not (observed).
  • The older enabled keys still work, but move off them. management.endpoints.enabled-by-default has been deprecated since 3.4 in favor of management.endpoints.access.default, and the per-endpoint enabled keys are part of the older endpoint enable/disable model. In 4.1.1 both are still honored (observed); prefer the access keys.
  • Don't set both models on one endpoint. In 4.1.1, setting management.endpoint.env.access and management.endpoint.env.enabled together stops the application from starting: the two are reported as mutually exclusive (observed).

The linter used to read only exposure.include and each endpoint's own access/enabled. Against this table, that produced six false positives and two false negatives (the missed heapdump and shutdown). It now resolves access and exposure the way the table shows.

2. Bracketed map keys

Some properties are maps whose keys contain dots, such as Kafka client settings. Bracket notation keeps the key intact (docs):

spring.kafka.properties[sasl.jaas.config]=...
spring.kafka.properties[security.protocol]=SASL_SSL
Enter fullscreen mode Exit fullscreen mode
  • For a Map<String, String>, x.map[a.b] and x.map.a.b bind the same entry, a.b (docs, observed).
  • Maps merge key by key across profiles (observed): a profile adding one entry keeps the base's other entries, and a profile overriding one entry replaces only that one. Lists behave differently (next section).
  • Brackets matter even when the characters are allowed. In this Map<String, String> binding test, app.map.com.foo-bar=A and app.map.com.foobar=B, without brackets, produced two map keys, com.foo-bar and com.foobar, but both got the same value: the dash was kept in the key name, while the value lookup treated the two spellings as one property (observed). With brackets, app.map[com.foo-bar] and app.map[com.foobar] got their own values.

In YAML, bracketed keys must be quoted (docs), and Spring's YAML loader attaches them to their parent without a dot:

spring:
  kafka:
    properties:
      "[security.protocol]": SASL_SSL   # spring.kafka.properties[security.protocol]
Enter fullscreen mode Exit fullscreen mode

The same holds for a quoted index: "[0]": under a key is list item 0 (observed). The linter originally joined keys with a dot (x.[0]), so a wildcard written as include: {"[0]": "*"} went undetected.

3. Lists are replaced whole

When a list is configured in more than one place, the whole list is replaced, lists of objects included: fields a profile doesn't write are not inherited from the base (docs). What I checked beyond that (observed):

  • The two tested formats behave as one list. A comma-separated value in application.properties (app.hosts=a,b) and an indexed list in application.yml (app.hosts[0]) are the same list; the .properties value wins as a whole.
  • Elements are trimmed. app.hosts=a, b ,c binds as [a, b, c].
  • An empty value is an empty list. app.hosts= binds [], not [""], and clears a list from a lower-precedence source. YAML [] does the same (in /actuator/env it shows as an empty string).
  • A profile can't skip an index. Defining only servers[1].url doesn't leave a gap: the application fails to start, reporting elements that were left unbound.

4. A scalar and a map on the same key

Base: app.feature=enabled. Profile: app.feature.mode=strict. In my tests, neither was removed from the property sources, and the target type decided which one mattered: a String field bound the scalar, a Map or an object bound the sub-keys, even when the ignored shape came from the higher-precedence profile (observed).

5. Where configuration comes from

  • application.yml and application.properties in the same location both load; .properties wins a conflict (docs, observed). The linter used to drop one of the two files.
  • A profile written in two places. Profile-specific files take precedence over non-specific ones (docs). With both application-prod.yml and a spring.config.activate.on-profile: prod block inside application.yml, in the tested configuration both contributed and application-prod.yml won the conflict (observed).
  • Several locations feed one environment, in order. At runtime, Spring Boot searches the classpath root, classpath /config, the current directory, ./config/ and its immediate subdirectories (docs). These locations are not merged with equal priority: their order determines precedence, and external files take precedence over packaged ones (docs). Since src/main/resources ends up on the classpath root, a project with files there and in config/ has one combined configuration, and a risky combination split across them (allowed-origins: "*" in one, allow-credentials: true in the other) is easy to miss in review. spring-config-guard evaluates each directory on its own, so it prints a coverage warning when it sees config in more than one location.

6. Spring Cloud Stream's Kafka binder has its own precedence

With Spring Cloud Stream, Kafka client settings don't come only from spring.kafka.*. The binder builds each client's configuration from (docs):

  1. Spring Boot's spring.kafka.* properties,
  2. overridden by spring.cloud.stream.kafka.binder.configuration.*,
  3. overridden by consumer-properties.* / producer-properties.*.

So security.protocol: SASL_PLAINTEXT in the binder's configuration map sends credentials unencrypted even when spring.kafka.security.protocol says SASL_SSL. A check that reads only spring.kafka.* misses it, and the linter did, until a real repository showed it.

A named binder can also have its own spring.cloud.stream.binders.<name>.environment.*, optionally inheriting the application's environment (docs). The implementation adds those entries as the first property source of that binder's environment, ahead of the inherited configuration (source: DefaultBinderFactory).

Two lessons from building the linter itself

Set.of doesn't provide a deterministic iteration order. One rule built a message by iterating a Set.of(...) of endpoint names. In our test, running the same jar five times on the same inputs produced between two and five different outputs in 8 of the 12 reference projects. For a tool that gates CI and whose reports get diffed, output has to be deterministic: iterate lists, and sort results with a total order.

Heuristics hide things. To avoid flagging values like token-validity-in-seconds: 86400, the secrets rule skipped purely numeric values. It also skipped ssl.keystore.password: 123456, found in a real sample repository. The fix narrowed the heuristic: a key that ends in a secret word names the secret itself, so a numeric value there is reported.

Takeaways

  • For properties bound through @ConfigurationProperties, /actuator/configprops shows what the application gets; /actuator/env shows the sources.
  • An Actuator endpoint is available over HTTP only when access is permitted and it is exposed. include, exclude, global and per-endpoint access, max-permitted and the management port all take part.
  • Maps merge by key; lists are replaced whole, including lists of objects.
  • Some ambiguous configurations don't fail silently: they stop the application from starting.

The test app, the Actuator scenarios script and the expected results are in the spring-config-guard repository (VALIDATION.md and ARCHITECTURE.md). If you know of a configuration where Spring behaves differently from what I describe, I'd like to hear about it.

Top comments (0)