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) { ... }
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:
-
excludewins overinclude(docs).include=*withexclude=env,heapdumpis a reasonable pattern. -
heapdumpandshutdownhave restricted access by default (docs), soinclude=*alone doesn't expose them. A globalaccess.default=unrestrictedlifts that restriction, and the wildcard then exposes both (observed).heapdumpis 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-onlyis about operations, not endpoints (docs). Withaccess.default=read-only,heapdump(a read operation) is available, butshutdown(write only) is not (observed). -
The older
enabledkeys still work, but move off them.management.endpoints.enabled-by-defaulthas been deprecated since 3.4 in favor ofmanagement.endpoints.access.default, and the per-endpointenabledkeys are part of the older endpoint enable/disable model. In 4.1.1 both are still honored (observed); prefer theaccesskeys. -
Don't set both models on one endpoint. In 4.1.1, setting
management.endpoint.env.accessandmanagement.endpoint.env.enabledtogether 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
-
For a
Map<String, String>,x.map[a.b]andx.map.a.bbind 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=Aandapp.map.com.foobar=B, without brackets, produced two map keys,com.foo-barandcom.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]andapp.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]
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 inapplication.yml(app.hosts[0]) are the same list; the.propertiesvalue wins as a whole. -
Elements are trimmed.
app.hosts=a, b ,cbinds 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/envit shows as an empty string). -
A profile can't skip an index. Defining only
servers[1].urldoesn'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.ymlandapplication.propertiesin the same location both load;.propertieswins 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.ymland aspring.config.activate.on-profile: prodblock insideapplication.yml, in the tested configuration both contributed andapplication-prod.ymlwon 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). Sincesrc/main/resourcesends up on the classpath root, a project with files there and inconfig/has one combined configuration, and a risky combination split across them (allowed-origins: "*"in one,allow-credentials: truein 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):
- Spring Boot's
spring.kafka.*properties, - overridden by
spring.cloud.stream.kafka.binder.configuration.*, - 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/configpropsshows what the application gets;/actuator/envshows 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-permittedand 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)