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
This commit is contained in:
Phillip Webb 2025-06-13 11:54:05 -07:00
parent 302a6e823f
commit 7613594b3a
11 changed files with 378 additions and 118 deletions

View File

@ -59,6 +59,22 @@ This means that you can specify active profiles in `application.properties` and
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.
[NOTE]
====
By default, profile names in Spring Boot may contain letters, numbers, or permitted characters (`-`, `_`, `.`, `+`, `@`).
In addition, they can only start and end with a letter or number.
This restriction helps to prevent common parsing issues.
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:
[configprops,yaml]
----
spring:
profiles:
validate: false
----
====
[[features.profiles.adding-active-profiles]]

View File

@ -297,14 +297,16 @@ class ConfigDataEnvironment {
ConfigurationPropertySource source = contributor.getConfigurationPropertySource();
if (source != null && !contributor.hasConfigDataOption(ConfigData.Option.IGNORE_PROFILES)) {
Binder binder = new Binder(Collections.singleton(source), placeholdersResolver);
binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST).ifBound((includes) -> {
if (!contributor.isActive(activationContext)) {
InactiveConfigDataAccessException.throwIfPropertyFound(contributor, Profiles.INCLUDE_PROFILES);
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
Profiles.INCLUDE_PROFILES.append("[0]"));
}
result.addAll(includes);
});
binder.bind(Profiles.INCLUDE_PROFILES, STRING_LIST, ProfilesValidator.get(binder))
.ifBound((includes) -> {
if (!contributor.isActive(activationContext)) {
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
Profiles.INCLUDE_PROFILES);
InactiveConfigDataAccessException.throwIfPropertyFound(contributor,
Profiles.INCLUDE_PROFILES.append("[0]"));
}
result.addAll(includes);
});
}
}
return result;

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -78,27 +78,35 @@ public class Profiles implements Iterable<String> {
* @param additionalProfiles any additional active profiles
*/
Profiles(Environment environment, Binder binder, Collection<String> additionalProfiles) {
this.groups = binder.bind("spring.profiles.group", STRING_STRINGS_MAP).orElseGet(LinkedMultiValueMap::new);
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, additionalProfiles));
this.defaultProfiles = expandProfiles(getDefaultProfiles(environment, binder));
ProfilesValidator validator = ProfilesValidator.get(binder);
if (additionalProfiles != null) {
validator.validate(additionalProfiles, () -> "Invalid profile property value found in additional profiles");
}
this.groups = binder.bind("spring.profiles.group", STRING_STRINGS_MAP, validator)
.orElseGet(LinkedMultiValueMap::new);
this.activeProfiles = expandProfiles(getActivatedProfiles(environment, binder, validator, additionalProfiles));
this.defaultProfiles = expandProfiles(getDefaultProfiles(environment, binder, validator));
}
private List<String> getActivatedProfiles(Environment environment, Binder binder,
private List<String> getActivatedProfiles(Environment environment, Binder binder, ProfilesValidator validator,
Collection<String> additionalProfiles) {
return asUniqueItemList(getProfiles(environment, binder, Type.ACTIVE), additionalProfiles);
return asUniqueItemList(getProfiles(environment, binder, validator, Type.ACTIVE), additionalProfiles);
}
private List<String> getDefaultProfiles(Environment environment, Binder binder) {
return asUniqueItemList(getProfiles(environment, binder, Type.DEFAULT));
private List<String> getDefaultProfiles(Environment environment, Binder binder, ProfilesValidator validator) {
return asUniqueItemList(getProfiles(environment, binder, validator, Type.DEFAULT));
}
private Collection<String> getProfiles(Environment environment, Binder binder, Type type) {
private Collection<String> getProfiles(Environment environment, Binder binder, ProfilesValidator validator,
Type type) {
String environmentPropertyValue = environment.getProperty(type.getName());
Set<String> environmentPropertyProfiles = (!StringUtils.hasLength(environmentPropertyValue))
? Collections.emptySet()
: StringUtils.commaDelimitedListToSet(StringUtils.trimAllWhitespace(environmentPropertyValue));
validator.validate(environmentPropertyProfiles,
() -> "Invalid profile property value found in Envronment under '%s'".formatted(type.getName()));
Set<String> environmentProfiles = new LinkedHashSet<>(Arrays.asList(type.get(environment)));
BindResult<Set<String>> boundProfiles = binder.bind(type.getName(), STRING_SET);
BindResult<Set<String>> boundProfiles = binder.bind(type.getName(), STRING_SET, validator);
if (hasProgrammaticallySetProfiles(type, environmentPropertyValue, environmentPropertyProfiles,
environmentProfiles)) {
if (!type.isMergeWithEnvironmentProfiles() || !boundProfiles.isBound()) {

View File

@ -0,0 +1,94 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.context.config;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.springframework.boot.context.properties.bind.BindContext;
import org.springframework.boot.context.properties.bind.BindHandler;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* {@link BindHandler} that validates profile names.
*
* @author Sijun Yang
* @author Phillip Webb
*/
final class ProfilesValidator implements BindHandler {
private static final String ALLOWED_CHARS = "-_.+@";
private final boolean validate;
private ProfilesValidator(boolean validate) {
this.validate = validate;
}
@Override
public Object onSuccess(ConfigurationPropertyName name, Bindable<?> target, BindContext context, Object result) {
validate(result);
return result;
}
void validate(Object value, Supplier<String> wrappedExceptionMessage) {
try {
validate(value);
}
catch (IllegalStateException ex) {
throw new IllegalStateException(wrappedExceptionMessage.get(), ex);
}
}
private void validate(Object value) {
if (!this.validate) {
return;
}
if (value instanceof Collection<?> list) {
list.forEach(this::validate);
return;
}
if (value instanceof Map<?, ?> map) {
map.forEach((k, v) -> validate(v));
return;
}
String profile = (value != null) ? value.toString() : null;
Assert.state(StringUtils.hasText(profile), "Invalid empty profile");
for (int i = 0; i < profile.length(); i++) {
int codePoint = profile.codePointAt(i);
boolean isAllowedChar = ALLOWED_CHARS.indexOf(codePoint) != -1;
Assert.state(isAllowedChar || Character.isLetterOrDigit(codePoint),
() -> "Profile '%s' must contain a letter, digit or allowed char (%s)".formatted(profile,
Arrays.stream(ALLOWED_CHARS.split("")).collect(Collectors.joining("', '", "'", "'"))));
Assert.state((i > 0 && i < profile.length() - 1) || Character.isLetterOrDigit(codePoint),
() -> "Profile '%s' must start and end with a letter or digit".formatted(profile));
}
}
static ProfilesValidator get(Binder binder) {
return new ProfilesValidator(binder.bind("spring.profiles.validate", Boolean.class).orElse(true));
}
}

View File

@ -146,7 +146,6 @@ public class StandardConfigDataLocationResolver
@Override
public List<StandardConfigDataResource> resolveProfileSpecific(ConfigDataLocationResolverContext context,
ConfigDataLocation location, Profiles profiles) {
validateProfiles(profiles);
return resolve(getProfileSpecificReferences(context, location.split(), profiles));
}
@ -162,27 +161,6 @@ public class StandardConfigDataLocationResolver
return references;
}
private void validateProfiles(Profiles profiles) {
for (String profile : profiles) {
validateProfile(profile);
}
}
private void validateProfile(String profile) {
Assert.hasText(profile, "'profile' must contain text");
Assert.state(!profile.startsWith("-") && !profile.startsWith("_"),
() -> String.format("Invalid profile '%s': must not start with '-' or '_'", profile));
Assert.state(!profile.endsWith("-") && !profile.endsWith("_"),
() -> String.format("Invalid profile '%s': must not end with '-' or '_'", profile));
profile.codePoints().forEach((codePoint) -> {
if (codePoint == '-' || codePoint == '_' || Character.isLetterOrDigit(codePoint)) {
return;
}
throw new IllegalStateException(
String.format("Invalid profile '%s': must contain only letters, digits, '-', or '_'", profile));
});
}
private String getResourceLocation(ConfigDataLocationResolverContext context,
ConfigDataLocation configDataLocation) {
String resourceLocation = configDataLocation.getNonPrefixedValue(PREFIX);

View File

@ -614,6 +614,13 @@
"sourceType": "org.springframework.boot.context.config.Profiles",
"description": "Unconditionally activate the specified comma-separated list of profiles (or list of profiles if using YAML)."
},
{
"name": "spring.profiles.validate",
"type": "java.lang.Boolean",
"sourceType": "org.springframework.boot.context.config.Profiles",
"description": "Whether profiles should be validated to ensure sensible names are used.",
"defaultValue": true
},
{
"name": "spring.reactor.debug-agent.enabled",
"type": "java.lang.Boolean",

View File

@ -1242,6 +1242,36 @@ class ConfigDataEnvironmentPostProcessorIntegrationTests {
assertThat(environment.getProperty("v2")).isEqualTo("root-p2");
}
@Test
@WithResource(name = "application.properties", content = """
spring.profiles.active=fa!l
""")
void invalidProfileActivePropertyThrowsException() {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.application.run())
.havingCause()
.withMessageContaining("must contain a letter");
}
@Test
@WithResource(name = "application.properties", content = """
spring.profiles.include=fa!l
""")
void invalidProfileIncludePropertyThrowsException() {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> this.application.run())
.havingCause()
.withMessageContaining("must contain a letter");
}
@Test
@WithResource(name = "application.properties", content = """
spring.profiles.active=p!1
spring.profiles.include=p!2
spring.profiles.validate=false
""")
void unvalidatedProfileProperties() {
assertThatNoException().isThrownBy(() -> this.application.run());
}
private Condition<ConfigurableEnvironment> matchingPropertySource(final String sourceName) {
return new Condition<>("environment containing property source " + sourceName) {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
@ -31,12 +32,16 @@ import org.springframework.core.env.Environment;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link Profiles}.
*
* @author Phillip Webb
* @author Madhura Bhave
* @author Sijun Yang
*/
class ProfilesTests {
@ -418,4 +423,59 @@ class ProfilesTests {
assertThat(profiles.getAccepted()).containsExactly("a", "e", "x", "y", "g", "f", "b", "c");
}
@Test
void validNamesArePermitted() {
assertValidName("spring.profiles.active", "ok");
assertValidName("spring.profiles.default", "ok");
assertValidName("spring.profiles.group.a", "ok");
}
@Test
void invalidNamesAreNotPermitted() {
assertInvalidName("spring.profiles.active", "fa!l");
assertInvalidName("spring.profiles.default", "fa!l");
assertInvalidName("spring.profiles.group.a", "fa!l");
}
@Test
void invalidNamesWhenValidationDisabledArePermitted() {
MockEnvironment environment = new MockEnvironment();
environment.setProperty("spring.profiles.validate", "false");
environment.setProperty("spring.profiles.active", "fa!l");
Binder binder = Binder.get(environment);
Profiles profiles = new Profiles(environment, binder, null);
assertThat(profiles.getAccepted()).containsExactly("fa!l");
}
@Test
void invalidNameInEnvironment() {
MockEnvironment environment = new MockEnvironment();
environment.setProperty("spring.profiles.active", "fa!l");
Binder binder = new Binder();
assertThatIllegalStateException().isThrownBy(() -> new Profiles(environment, binder, null))
.withMessage("Invalid profile property value found in Envronment under 'spring.profiles.active'");
}
@Test
void invalidNameInActive() {
MockEnvironment environment = new MockEnvironment();
Binder binder = new Binder();
assertThatIllegalStateException().isThrownBy(() -> new Profiles(environment, binder, Set.of("fa!l")))
.withMessage("Invalid profile property value found in additional profiles");
}
private void assertValidName(String name, String value) {
MockEnvironment environment = new MockEnvironment();
environment.setProperty(name, value);
Binder binder = Binder.get(environment);
assertThatNoException().isThrownBy(() -> new Profiles(environment, binder, null));
}
private void assertInvalidName(String name, String value) {
MockEnvironment environment = new MockEnvironment();
environment.setProperty(name, value);
Binder binder = Binder.get(environment);
assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> new Profiles(environment, binder, null));
}
}

View File

@ -0,0 +1,138 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.context.config;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link ProfilesValidator}.
*
* @author Phillip Webb
* @author Sijun Yang
*/
class ProfilesValidatorTests {
private static final Bindable<String> STRING = Bindable.of(String.class);
private static final Bindable<List<String>> STRING_LIST = Bindable.listOf(String.class);
private static final Bindable<Map<String, String>> STRING_STRING_MAP = Bindable.mapOf(String.class, String.class);
@Test
void validateWhenValid() {
assertValid("test");
assertValid("dev-test");
assertValid("dev-test_123");
assertValid("dev-테스트_123");
assertValid("d-e_v-t-.e_@@s+t");
}
@Test
void validateWhenInvalidThrowsException() {
assertInvalid("-dev");
assertInvalid("_dev");
assertInvalid("+dev");
assertInvalid(".dev");
assertInvalid("dev_");
assertInvalid("dev*test");
}
@Test
void validateWhenInvalidBoundStringThrowsException() {
assertInvalid(Map.of("profile", "dev*test"), STRING);
}
@Test
void validateWhenInvalidBoundCollectionThrowsException() {
assertInvalid(Map.of("profile", "dev*test"), STRING_LIST);
}
@Test
void validateWhenInvalidBoundCollectionFromIndexedThrowsException() {
assertInvalid(Map.of("profile[0]", "ok,", "profile[1]", "dev*test"), STRING_LIST);
}
@Test
void validateWhenInvalidBoundMapFromIndexedThrowsException() {
assertInvalid(Map.of("profile.foo", "dev*test"), STRING_STRING_MAP);
}
@Test
void validateWhenInvalidThrowsUsefulExceptionMessage() {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> bind(Map.of("profile", "b*d")))
.havingCause()
.withMessageContaining(
"Profile 'b*d' must contain a letter, digit or allowed char ('-', '_', '.', '+', '@')");
}
@Test
void validateWhenInvalidStartCharacterThrowsUsefulExceptionMessage() {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> bind(Map.of("profile", "_bad")))
.havingCause()
.withMessageContaining("Profile '_bad' must start and end with a letter or digit");
}
@Test
void validateWithWrappedExceptionMessageWhenValid() {
assertThatNoException().isThrownBy(() -> ProfilesValidator.get(new Binder()).validate("ok", () -> "context"));
}
@Test
void validateWithWrappedExceptionMessageWhenInvalidThrowsException() {
assertThatIllegalStateException()
.isThrownBy(() -> ProfilesValidator.get(new Binder()).validate("b*d", () -> "context"))
.withMessage("context")
.havingCause()
.withMessageContaining("must contain a letter");
}
private void assertValid(String value) {
assertThatNoException().isThrownBy(() -> bind(Map.of("profile", value)));
}
private void assertInvalid(String value) {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> bind(Map.of("profile", value)));
}
private <T> void assertInvalid(Map<String, String> map, Bindable<T> target) {
assertThatExceptionOfType(BindException.class).isThrownBy(() -> bind(map, target));
}
private void bind(Map<?, ?> map) {
bind(map, STRING);
}
private <T> void bind(Map<?, ?> map, Bindable<T> target) {
MapConfigurationPropertySource source = new MapConfigurationPropertySource(map);
Binder binder = new Binder(source);
ProfilesValidator validator = ProfilesValidator.get(binder);
binder.bind("profile", target, validator);
}
}

View File

@ -306,83 +306,6 @@ class StandardConfigDataLocationResolverTests {
assertThatNoException().isThrownBy(() -> this.resolver.resolve(this.context, location));
}
@Test
void resolveProfileSpecificWhenProfileIsValidShouldNotThrowException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("dev-test_123");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatNoException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles));
}
@Test
void resolveProfileSpecificWithNonAsciiCharactersShouldNotThrowException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("dev-테스트_123");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatNoException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles));
}
@Test
void resolveProfileSpecificWithAdditionalValidProfilesShouldNotThrowException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("dev-test");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, List.of("prod-test", "stage-test"));
assertThatNoException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles));
}
@Test
void resolveProfileSpecificWhenProfileStartsWithDashThrowsException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("-dev");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatIllegalStateException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles))
.withMessageStartingWith("Invalid profile '-dev': must not start with '-' or '_'");
}
@Test
void resolveProfileSpecificWhenProfileStartsWithUnderscoreThrowsException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("_dev");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatIllegalStateException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles))
.withMessageStartingWith("Invalid profile '_dev': must not start with '-' or '_'");
}
@Test
void resolveProfileSpecificWhenProfileEndsWithDashThrowsException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("dev-");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatIllegalStateException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles))
.withMessageStartingWith("Invalid profile 'dev-': must not end with '-' or '_'");
}
@Test
void resolveProfileSpecificWhenProfileEndsWithUnderscoreThrowsException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("dev_");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatIllegalStateException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles))
.withMessageStartingWith("Invalid profile 'dev_': must not end with '-' or '_'");
}
@Test
void resolveProfileSpecificWhenProfileContainsInvalidCharactersThrowsException() {
ConfigDataLocation location = ConfigDataLocation.of("classpath:/configdata/properties/");
this.environment.setActiveProfiles("dev*test");
Profiles profiles = new Profiles(this.environment, this.environmentBinder, Collections.emptyList());
assertThatIllegalStateException()
.isThrownBy(() -> this.resolver.resolveProfileSpecific(this.context, location, profiles))
.withMessageStartingWith("Invalid profile 'dev*test': must contain only letters, digits, '-', or '_'");
}
private String filePath(String... components) {
return "file [" + String.join(File.separator, components) + "]";
}

View File

@ -1,2 +1,6 @@
test.name=Phil
sample.name=Andy
spring.profiles.validate=false
spring.profiles.active=a+very(silly)!name