Skip to content

Commit 7613594

Browse files
committed
Relax profile naming restrictions and allow opt-out
Rework profile name validation logic so that `.` and `+` and `@` can be used in the names. Also provide an opt-out property that can be set to restore earlier Spring Boot behavior. The commit also include an update to the reference documentation. Fixes gh-45947
1 parent 302a6e8 commit 7613594

File tree

11 files changed

+378
-118
lines changed

11 files changed

+378
-118
lines changed

spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/profiles.adoc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ This means that you can specify active profiles in `application.properties` and
5959

6060
TIP: See xref:features/external-config.adoc#features.external-config.order[the "`Externalized Configuration`"] for more details on the order in which property sources are considered.
6161

62+
[NOTE]
63+
====
64+
By default, profile names in Spring Boot may contain letters, numbers, or permitted characters (`-`, `_`, `.`, `+`, `@`).
65+
In addition, they can only start and end with a letter or number.
66+
67+
This restriction helps to prevent common parsing issues.
68+
if, however, you prefer more flexible profile names you can set configprop:spring.profiles.validate[] to `false` in your `application.properties` or `application.yaml` file:
69+
70+
[configprops,yaml]
71+
----
72+
spring:
73+
profiles:
74+
validate: false
75+
----
76+
====
77+
6278

6379

6480
[[features.profiles.adding-active-profiles]]

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/ConfigDataEnvironment.java

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -297,14 +297,16 @@ private Collection<? extends String> getIncludedProfiles(ConfigDataEnvironmentCo
297297
ConfigurationPropertySource source = contributor.getConfigurationPropertySource();
298298
if (source != null && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
299299
Binder binder = new Binder(Collections.singleton(source), placeholdersResolver);
300-
binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST).ifBound((includes) -> {
301-
if (!contributor.isActive(activationContext)) {
302-
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES);
303-
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
304-
Profiles.INCLUDE_PROFILES.append("[0]"));
305-
}
306-
result.addAll(includes);
307-
});
300+
binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST, ProfilesValidator.get(binder))
301+
.ifBound((includes) -> {
302+
if (!contributor.isActive(activationContext)) {
303+
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
304+
Profiles.INCLUDE_PROFILES);
305+
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
306+
Profiles.INCLUDE_PROFILES.append("[0]"));
307+
}
308+
result.addAll(includes);
309+
});
308310
}
309311
}
310312
return result;

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/Profiles.java

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -78,27 +78,35 @@ public class Profiles implements Iterable<String> {
7878
* @param additionalProfiles any additional active profiles
7979
*/
8080
Profiles(Environment environment, Binder binder, Collection<String> additionalProfiles) {
81-
this.groups = binder.bind("spring.profiles.group", STRING_STRINGS_MAP).orElseGet(LinkedMultiValueMap::new);
82-
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, additionalProfiles));
83-
this.defaultProfiles = expandProfiles(getDefaultProfiles(environment, binder));
81+
ProfilesValidator validator = ProfilesValidator.get(binder);
82+
if (additionalProfiles != null) {
83+
validator.validate(additionalProfiles, () -> "Invalid profile property value found in additional profiles");
84+
}
85+
this.groups = binder.bind("spring.profiles.group", STRING_STRINGS_MAP, validator)
86+
.orElseGet(LinkedMultiValueMap::new);
87+
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, validator, additionalProfiles));
88+
this.defaultProfiles = expandProfiles(getDefaultProfiles(environment, binder, validator));
8489
}
8590

86-
private List<String> getActivatedProfiles(Environment environment, Binder binder,
91+
private List<String> getActivatedProfiles(Environment environment, Binder binder, ProfilesValidator validator,
8792
Collection<String> additionalProfiles) {
88-
return asUniqueItemList(getProfiles(environment, binder, Type.ACTIVE), additionalProfiles);
93+
return asUniqueItemList(getProfiles(environment, binder, validator, Type.ACTIVE), additionalProfiles);
8994
}
9095

91-
private List<String> getDefaultProfiles(Environment environment, Binder binder) {
92-
return asUniqueItemList(getProfiles(environment, binder, Type.DEFAULT));
96+
private List<String> getDefaultProfiles(Environment environment, Binder binder, ProfilesValidator validator) {
97+
return asUniqueItemList(getProfiles(environment, binder, validator, Type.DEFAULT));
9398
}
9499

95-
private Collection<String> getProfiles(Environment environment, Binder binder, Type type) {
100+
private Collection<String> getProfiles(Environment environment, Binder binder, ProfilesValidator validator,
101+
Type type) {
96102
String environmentPropertyValue = environment.getProperty(type.getName());
97103
Set<String> environmentPropertyProfiles = (!StringUtils.hasLength(environmentPropertyValue))
98104
? Collections.emptySet()
99105
: StringUtils.commaDelimitedListToSet(StringUtils.trimAllWhitespace(environmentPropertyValue));
106+
validator.validate(environmentPropertyProfiles,
107+
() -> "Invalid profile property value found in Envronment under '%s'".formatted(type.getName()));
100108
Set<String> environmentProfiles = new LinkedHashSet<>(Arrays.asList(type.get(environment)));
101-
BindResult<Set<String>> boundProfiles = binder.bind(type.getName(), STRING_SET);
109+
BindResult<Set<String>> boundProfiles = binder.bind(type.getName(), STRING_SET, validator);
102110
if (hasProgrammaticallySetProfiles(type, environmentPropertyValue, environmentPropertyProfiles,
103111
environmentProfiles)) {
104112
if (!type.isMergeWithEnvironmentProfiles() || !boundProfiles.isBound()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2012-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.context.config;
18+
19+
import java.util.Arrays;
20+
import java.util.Collection;
21+
import java.util.Map;
22+
import java.util.function.Supplier;
23+
import java.util.stream.Collectors;
24+
25+
import org.springframework.boot.context.properties.bind.BindContext;
26+
import org.springframework.boot.context.properties.bind.BindHandler;
27+
import org.springframework.boot.context.properties.bind.Bindable;
28+
import org.springframework.boot.context.properties.bind.Binder;
29+
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
30+
import org.springframework.util.Assert;
31+
import org.springframework.util.StringUtils;
32+
33+
/**
34+
* {@link BindHandler} that validates profile names.
35+
*
36+
* @author Sijun Yang
37+
* @author Phillip Webb
38+
*/
39+
final class ProfilesValidator implements BindHandler {
40+
41+
private static final String ALLOWED_CHARS = "-_.+@";
42+
43+
private final boolean validate;
44+
45+
private ProfilesValidator(boolean validate) {
46+
this.validate = validate;
47+
}
48+
49+
@Override
50+
public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
51+
validate(result);
52+
return result;
53+
}
54+
55+
void validate(Object value, Supplier<String> wrappedExceptionMessage) {
56+
try {
57+
validate(value);
58+
}
59+
catch (IllegalStateException ex) {
60+
throw new IllegalStateException(wrappedExceptionMessage.get(), ex);
61+
}
62+
}
63+
64+
private void validate(Object value) {
65+
if (!this.validate) {
66+
return;
67+
}
68+
if (value instanceof Collection<?> list) {
69+
list.forEach(this::validate);
70+
return;
71+
}
72+
if (value instanceof Map<?, ?> map) {
73+
map.forEach((k, v) -> validate(v));
74+
return;
75+
}
76+
String profile = (value != null) ? value.toString() : null;
77+
Assert.state(StringUtils.hasText(profile), "Invalid empty profile");
78+
for (int i = 0; i < profile.length(); i++) {
79+
int codePoint = profile.codePointAt(i);
80+
boolean isAllowedChar = ALLOWED_CHARS.indexOf(codePoint) != -1;
81+
Assert.state(isAllowedChar || Character.isLetterOrDigit(codePoint),
82+
() -> "Profile '%s' must contain a letter, digit or allowed char (%s)".formatted(profile,
83+
Arrays.stream(ALLOWED_CHARS.split("")).collect(Collectors.joining("', '", "'", "'"))));
84+
Assert.state((i > 0 && i < profile.length() - 1) || Character.isLetterOrDigit(codePoint),
85+
() -> "Profile '%s' must start and end with a letter or digit".formatted(profile));
86+
}
87+
88+
}
89+
90+
static ProfilesValidator get(Binder binder) {
91+
return new ProfilesValidator(binder.bind("spring.profiles.validate", Boolean.class).orElse(true));
92+
}
93+
94+
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/config/StandardConfigDataLocationResolver.java

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ private Set<StandardConfigDataReference> getReferences(ConfigDataLocationResolve
146146
@Override
147147
public List<StandardConfigDataResource> resolveProfileSpecific(ConfigDataLocationResolverContext context,
148148
ConfigDataLocation location, Profiles profiles) {
149-
validateProfiles(profiles);
150149
return resolve(getProfileSpecificReferences(context, location.split(), profiles));
151150
}
152151

@@ -162,27 +161,6 @@ private Set<StandardConfigDataReference> getProfileSpecificReferences(ConfigData
162161
return references;
163162
}
164163

165-
private void validateProfiles(Profiles profiles) {
166-
for (String profile : profiles) {
167-
validateProfile(profile);
168-
}
169-
}
170-
171-
private void validateProfile(String profile) {
172-
Assert.hasText(profile, "'profile' must contain text");
173-
Assert.state(!profile.startsWith("-") && !profile.startsWith("_"),
174-
() -> String.format("Invalid profile '%s': must not start with '-' or '_'", profile));
175-
Assert.state(!profile.endsWith("-") && !profile.endsWith("_"),
176-
() -> String.format("Invalid profile '%s': must not end with '-' or '_'", profile));
177-
profile.codePoints().forEach((codePoint) -> {
178-
if (codePoint == '-' || codePoint == '_' || Character.isLetterOrDigit(codePoint)) {
179-
return;
180-
}
181-
throw new IllegalStateException(
182-
String.format("Invalid profile '%s': must contain only letters, digits, '-', or '_'", profile));
183-
});
184-
}
185-
186164
private String getResourceLocation(ConfigDataLocationResolverContext context,
187165
ConfigDataLocation configDataLocation) {
188166
String resourceLocation = configDataLocation.getNonPrefixedValue(PREFIX);

spring-boot-project/spring-boot/src/main/resources/META-INF/additional-spring-configuration-metadata.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,13 @@
614614
"sourceType": "org.springframework.boot.context.config.Profiles",
615615
"description": "Unconditionally activate the specified comma-separated list of profiles (or list of profiles if using YAML)."
616616
},
617+
{
618+
"name": "spring.profiles.validate",
619+
"type": "java.lang.Boolean",
620+
"sourceType": "org.springframework.boot.context.config.Profiles",
621+
"description": "Whether profiles should be validated to ensure sensible names are used.",
622+
"defaultValue": true
623+
},
617624
{
618625
"name": "spring.reactor.debug-agent.enabled",
619626
"type": "java.lang.Boolean",

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ConfigDataEnvironmentPostProcessorIntegrationTests.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,36 @@ void runWhenHasFilesInRootAndConfigWithProfiles() {
12421242
assertThat(environment.getProperty("v2")).isEqualTo("root-p2");
12431243
}
12441244

1245+
@Test
1246+
@WithResource(name = "application.properties", content = """
1247+
spring.profiles.active=fa!l
1248+
""")
1249+
void invalidProfileActivePropertyThrowsException() {
1250+
assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.application.run())
1251+
.havingCause()
1252+
.withMessageContaining("must contain a letter");
1253+
}
1254+
1255+
@Test
1256+
@WithResource(name = "application.properties", content = """
1257+
spring.profiles.include=fa!l
1258+
""")
1259+
void invalidProfileIncludePropertyThrowsException() {
1260+
assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.application.run())
1261+
.havingCause()
1262+
.withMessageContaining("must contain a letter");
1263+
}
1264+
1265+
@Test
1266+
@WithResource(name = "application.properties", content = """
1267+
spring.profiles.active=p!1
1268+
spring.profiles.include=p!2
1269+
spring.profiles.validate=false
1270+
""")
1271+
void unvalidatedProfileProperties() {
1272+
assertThatNoException().isThrownBy(() -> this.application.run());
1273+
}
1274+
12451275
private Condition<ConfigurableEnvironment> matchingPropertySource(final String sourceName) {
12461276
return new Condition<>("environment containing property source " + sourceName) {
12471277

spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/config/ProfilesTests.java

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2021 the original author or authors.
2+
* Copyright 2012-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@
2020
import java.util.Arrays;
2121
import java.util.Collections;
2222
import java.util.List;
23+
import java.util.Set;
2324

2425
import org.junit.jupiter.api.Test;
2526

@@ -31,12 +32,16 @@
3132
import org.springframework.mock.env.MockEnvironment;
3233

3334
import static org.assertj.core.api.Assertions.assertThat;
35+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
36+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
37+
import static org.assertj.core.api.Assertions.assertThatNoException;
3438

3539
/**
3640
* Tests for {@link Profiles}.
3741
*
3842
* @author Phillip Webb
3943
* @author Madhura Bhave
44+
* @author Sijun Yang
4045
*/
4146
class ProfilesTests {
4247

@@ -418,4 +423,59 @@ void complexRecursiveReferenceInProfileGroupIgnoresDuplicates() {
418423
assertThat(profiles.getAccepted()).containsExactly("a", "e", "x", "y", "g", "f", "b", "c");
419424
}
420425

426+
@Test
427+
void validNamesArePermitted() {
428+
assertValidName("spring.profiles.active", "ok");
429+
assertValidName("spring.profiles.default", "ok");
430+
assertValidName("spring.profiles.group.a", "ok");
431+
}
432+
433+
@Test
434+
void invalidNamesAreNotPermitted() {
435+
assertInvalidName("spring.profiles.active", "fa!l");
436+
assertInvalidName("spring.profiles.default", "fa!l");
437+
assertInvalidName("spring.profiles.group.a", "fa!l");
438+
}
439+
440+
@Test
441+
void invalidNamesWhenValidationDisabledArePermitted() {
442+
MockEnvironment environment = new MockEnvironment();
443+
environment.setProperty("spring.profiles.validate", "false");
444+
environment.setProperty("spring.profiles.active", "fa!l");
445+
Binder binder = Binder.get(environment);
446+
Profiles profiles = new Profiles(environment, binder, null);
447+
assertThat(profiles.getAccepted()).containsExactly("fa!l");
448+
}
449+
450+
@Test
451+
void invalidNameInEnvironment() {
452+
MockEnvironment environment = new MockEnvironment();
453+
environment.setProperty("spring.profiles.active", "fa!l");
454+
Binder binder = new Binder();
455+
assertThatIllegalStateException().isThrownBy(() -> new Profiles(environment, binder, null))
456+
.withMessage("Invalid profile property value found in Envronment under 'spring.profiles.active'");
457+
}
458+
459+
@Test
460+
void invalidNameInActive() {
461+
MockEnvironment environment = new MockEnvironment();
462+
Binder binder = new Binder();
463+
assertThatIllegalStateException().isThrownBy(() -> new Profiles(environment, binder, Set.of("fa!l")))
464+
.withMessage("Invalid profile property value found in additional profiles");
465+
}
466+
467+
private void assertValidName(String name, String value) {
468+
MockEnvironment environment = new MockEnvironment();
469+
environment.setProperty(name, value);
470+
Binder binder = Binder.get(environment);
471+
assertThatNoException().isThrownBy(() -> new Profiles(environment, binder, null));
472+
}
473+
474+
private void assertInvalidName(String name, String value) {
475+
MockEnvironment environment = new MockEnvironment();
476+
environment.setProperty(name, value);
477+
Binder binder = Binder.get(environment);
478+
assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> new Profiles(environment, binder, null));
479+
}
480+
421481
}

0 commit comments

Comments
 (0)