From dd5a427d96bff24a925e5b29f6cc13d2912f54a4 Mon Sep 17 00:00:00 2001
From: Seongho Bae Spring Cloud Config 5.0.4 treats a non-null empty URI as valid when
+ * {@code cloneOnStart} is false, so a missing or blank {@code CONFIG_REPO_URI}
+ * would otherwise start the process and defer Git work to the first request.
+ * Call this check during default-profile startup, before any clone. Operators: set {@code CONFIG_REPO_URI} to the reviewed Git URI, then start
+ * the default profile. Use the {@code native} profile only for local fixtures
+ * that must not depend on a remote. The {@code native} profile is excluded so reviewed local fixtures can start
+ * without a remote. Do not use {@code native} as a production fallback. Operators: set {@code CONFIG_REPO_URI} to a deployment-owned Git URI
+ * before starting the default profile. Use {@code native} only for reviewed
+ * local fixtures that do not need a remote. Spring Cloud Config 5.0.4 treats a non-null empty URI as valid when
* {@code cloneOnStart} is false, so a missing or blank {@code CONFIG_REPO_URI}
* would otherwise start the process and defer Git work to the first request.
- * Call this check during default-profile startup, before any clone.
Operators: set {@code CONFIG_REPO_URI} to the reviewed Git URI, then start * the default profile. Use the {@code native} profile only for local fixtures - * that must not depend on a remote.
+ * that must not depend on a remote. Do not combine {@code native} with + * {@code prod} or {@code production} unless {@code xtrmetl.config.allow-native=true} + * is an approved fixture exception. */ public final class ConfigServerRepositoryAuthority { @@ -18,8 +26,18 @@ public final class ConfigServerRepositoryAuthority { "CONFIG_REPO_URI must name a deployment-owned Git repository; " + "blank, unresolved, or demo authority is rejected before remote Git access"; + static final String REQUEST_TEMPLATE_MESSAGE = + "CONFIG_REPO_URI must be a concrete repository destination; " + + "{application}, {profile}, and {label} placeholders are rejected"; + private static final String UNRESOLVED_PLACEHOLDER = "${CONFIG_REPO_URI"; - private static final String DEMO_REMOTE = "your-repo/config-repo.git"; + private static final String RETIRED_DEMO_HOST = "github.com"; + private static final String RETIRED_DEMO_PATH = "/your-repo/config-repo"; + private static final Pattern REQUEST_TEMPLATE = + Pattern.compile("\\{(application|profile|label)\\}", Pattern.CASE_INSENSITIVE); + private static final Pattern SCP_DEMO = Pattern.compile( + "(?i)(?:^|@)github\\.com[:/]your-repo/config-repo(?:\\.git)?/?$" + ); private ConfigServerRepositoryAuthority() { } @@ -28,15 +46,82 @@ private ConfigServerRepositoryAuthority() { * Rejects repository values that are not an operator-supplied destination. * * @param repositoryUri bound {@code spring.cloud.config.server.git.uri} value - * @throws IllegalStateException when the URI is missing, blank, still a - * placeholder, or the retired demo remote + * @throws IllegalStateException when the URI is missing, blank after Unicode + * trim, still a placeholder, the retired demo + * remote, or a request-templated Git location */ public static void requireExplicitRepository(String repositoryUri) { - if (repositoryUri == null - || repositoryUri.isBlank() - || repositoryUri.contains(UNRESOLVED_PLACEHOLDER) - || repositoryUri.contains(DEMO_REMOTE)) { + if (repositoryUri == null) { + throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); + } + String trimmed = trimAuthority(repositoryUri); + if (trimmed.isEmpty() + || trimmed.contains(UNRESOLVED_PLACEHOLDER) + || isRetiredDemoRemote(trimmed)) { throw new IllegalStateException(MISSING_AUTHORITY_MESSAGE); } + if (REQUEST_TEMPLATE.matcher(trimmed).find()) { + throw new IllegalStateException(REQUEST_TEMPLATE_MESSAGE); + } + } + + /** + * Removes leading and trailing Unicode space and format padding. + * + *{@link String#isBlank()} and {@link String#strip()} miss NBSP and + * zero-width padding, which would otherwise pass as a non-empty URI.
+ * + * @param repositoryUri raw bound value + * @return value with ignorable padding removed from both ends + */ + static String trimAuthority(String repositoryUri) { + int start = 0; + int end = repositoryUri.length(); + while (start < end && isIgnorablePad(repositoryUri.codePointAt(start))) { + start += Character.charCount(repositoryUri.codePointAt(start)); + } + while (end > start) { + int codePoint = repositoryUri.codePointBefore(end); + if (!isIgnorablePad(codePoint)) { + break; + } + end -= Character.charCount(codePoint); + } + return repositoryUri.substring(start, end); + } + + static boolean isRetiredDemoRemote(String repositoryUri) { + if (SCP_DEMO.matcher(repositoryUri).find()) { + return true; + } + try { + URI uri = URI.create(repositoryUri); + String host = uri.getHost(); + String path = uri.getPath(); + if (host == null || path == null) { + return false; + } + return RETIRED_DEMO_HOST.equalsIgnoreCase(host) && isRetiredDemoPath(path); + } catch (IllegalArgumentException ex) { + return false; + } + } + + private static boolean isRetiredDemoPath(String path) { + String normalized = path.toLowerCase(Locale.ROOT); + if (normalized.endsWith(".git")) { + normalized = normalized.substring(0, normalized.length() - 4); + } + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return RETIRED_DEMO_PATH.equals(normalized); + } + + private static boolean isIgnorablePad(int codePoint) { + return Character.isWhitespace(codePoint) + || Character.isSpaceChar(codePoint) + || codePoint == 0x200B + || codePoint == 0xFEFF; } } diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java new file mode 100644 index 00000000..bf09ac6b --- /dev/null +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java @@ -0,0 +1,82 @@ +package com.xtrmetl.config; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.config.ConfigDataEnvironmentPostProcessor; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.Profiles; + +import java.util.Map; + +/** + * Rejects blank, demo, unresolved, or request-templated Git authority before + * JGit {@code afterPropertiesSet}. + * + *Spring Cloud Config 5.0.4 constructs {@code JGitEnvironmentRepository} as + * an unordered {@code InitializingBean}. A validator bean can therefore run + * after {@code cloneOnStart=true} has already contacted a remote. This + * processor runs after config-data load and before context refresh.
+ * + *Operators: set {@code CONFIG_REPO_URI} to a reviewed Git URI, then start + * the default profile. The {@code native} profile skips the Git URI check so + * local fixtures can start. Combining {@code native} with {@code prod} or + * {@code production} requires {@code xtrmetl.config.allow-native=true}.
+ */ +@Order(ConfigDataEnvironmentPostProcessor.ORDER + 1) +public class ConfigServerRepositoryAuthorityEnvironmentPostProcessor implements EnvironmentPostProcessor { + + static final String PROPERTY_SOURCE_NAME = "config-server-repository-authority"; + static final String GIT_URI_PROPERTY = "spring.cloud.config.server.git.uri"; + static final String ALLOW_NATIVE_PROPERTY = "xtrmetl.config.allow-native"; + static final String NATIVE_PRODUCTION_MESSAGE = + "native profile cannot be combined with prod or production; " + + "set CONFIG_REPO_URI and start the default Git profile, " + + "or set xtrmetl.config.allow-native=true only for an approved fixture"; + + /** + * Fails closed on incompatible profiles or missing Git authority. + * + * @param environment configurable application environment + * @param application Spring application being prepared + */ + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + requireCompatibleProfiles(environment); + if (environment.acceptsProfiles(Profiles.of("native"))) { + return; + } + String repositoryUri; + try { + repositoryUri = environment.getProperty(GIT_URI_PROPERTY); + } catch (IllegalArgumentException ex) { + throw new IllegalStateException(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, ex); + } + ConfigServerRepositoryAuthority.requireExplicitRepository(repositoryUri); + String trimmed = ConfigServerRepositoryAuthority.trimAuthority(repositoryUri); + if (!trimmed.equals(repositoryUri)) { + environment.getPropertySources().addFirst(new MapPropertySource( + PROPERTY_SOURCE_NAME, + Map.of(GIT_URI_PROPERTY, trimmed) + )); + } + } + + /** + * Keeps {@code native} fixture-only unless an operator opts in. + * + * @param environment active Spring environment + */ + static void requireCompatibleProfiles(Environment environment) { + boolean nativeProfile = environment.acceptsProfiles(Profiles.of("native")); + boolean production = environment.acceptsProfiles(Profiles.of("prod", "production")); + boolean allowNative = Boolean.parseBoolean( + environment.getProperty(ALLOW_NATIVE_PROPERTY, "false") + ); + if (nativeProfile && production && !allowNative) { + throw new IllegalStateException(NATIVE_PRODUCTION_MESSAGE); + } + } +} diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java index 8c4708fe..9a7d4fb7 100644 --- a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityValidator.java @@ -6,10 +6,13 @@ import org.springframework.stereotype.Component; /** - * Fails default-profile startup when Git repository authority is not explicit. + * Defense-in-depth fail-closed check after context refresh begins. * - *The {@code native} profile is excluded so reviewed local fixtures can start - * without a remote. Do not use {@code native} as a production fallback.
+ *{@link ConfigServerRepositoryAuthorityEnvironmentPostProcessor} is the + * authority that runs before JGit. This bean remains so a missing processor + * registration still stops the default profile. The {@code native} profile is + * excluded so reviewed local fixtures can start without a remote. Do not use + * {@code native} as a production fallback.
*/ @Component @Profile("!native") diff --git a/config-server/src/main/resources/META-INF/spring.factories b/config-server/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..19c74c50 --- /dev/null +++ b/config-server/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor diff --git a/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor b/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor new file mode 100644 index 00000000..3dd9ddd1 --- /dev/null +++ b/config-server/src/main/resources/META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor @@ -0,0 +1 @@ +com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java new file mode 100644 index 00000000..1b3353f4 --- /dev/null +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java @@ -0,0 +1,85 @@ +package com.xtrmetl.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.mock.env.MockEnvironment; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies the pre-JGit EnvironmentPostProcessor on realistic profile and URI combinations. + */ +class ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest { + + private final ConfigServerRepositoryAuthorityEnvironmentPostProcessor processor = + new ConfigServerRepositoryAuthorityEnvironmentPostProcessor(); + private final SpringApplication application = new SpringApplication(ConfigServerApplication.class); + + @Test + void defaultProfileRejectsBlankAndDemoBeforeContextRefresh() { + MockEnvironment blank = new MockEnvironment(); + blank.setProperty("spring.cloud.config.server.git.uri", ""); + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(blank, application) + ); + + MockEnvironment demo = new MockEnvironment(); + demo.setProperty( + "spring.cloud.config.server.git.uri", + "https://github.com/your-repo/config-repo.git" + ); + assertEquals( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(demo, application) + ).getMessage() + ); + } + + @Test + void defaultProfileAcceptsExplicitAuthorityAndTrimsPadding() { + MockEnvironment environment = new MockEnvironment(); + environment.setProperty( + "spring.cloud.config.server.git.uri", + " https://git.example.internal/config-repo.git " + ); + assertDoesNotThrow(() -> processor.postProcessEnvironment(environment, application)); + assertEquals( + "https://git.example.internal/config-repo.git", + environment.getProperty("spring.cloud.config.server.git.uri") + ); + } + + @Test + void nativeProfileSkipsGitUriCheck() { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("native"); + assertDoesNotThrow(() -> processor.postProcessEnvironment(environment, application)); + } + + @Test + void nativeCombinedWithProductionFailsUnlessExplicitlyAllowed() { + MockEnvironment blocked = new MockEnvironment(); + blocked.setActiveProfiles("native", "prod"); + blocked.setProperty( + "spring.cloud.config.server.git.uri", + "https://git.example.internal/config-repo.git" + ); + assertEquals( + ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(blocked, application) + ).getMessage() + ); + + MockEnvironment allowed = new MockEnvironment(); + allowed.setActiveProfiles("native", "production"); + allowed.setProperty("xtrmetl.config.allow-native", "true"); + assertDoesNotThrow(() -> processor.postProcessEnvironment(allowed, application)); + } +} diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java index 5ef6ca26..35dfdd66 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -10,8 +10,9 @@ import static org.junit.jupiter.api.Assertions.fail; /** - * Starts the default Git-backed Config Server and proves blank repository - * authority cannot become a running process. + * Starts the default Git-backed Config Server and proves blank or demo + * repository authority cannot become a running process, including when + * {@code cloneOnStart} is true. * *Operators: set {@code CONFIG_REPO_URI} to a deployment-owned Git URI
* before starting the default profile. Use {@code native} only for reviewed
@@ -20,59 +21,86 @@
class ConfigServerRepositoryAuthorityLiveTest {
@Test
- void blankRepositoryUriFailsClosedBeforeGitAccess() {
+ void blankRepositoryUriFailsClosedWithAuthorityMessage() {
+ assertStartupFailure(
+ ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE,
+ "--spring.cloud.config.server.git.uri=",
+ "--spring.cloud.config.server.git.clone-on-start=true"
+ );
+ }
+
+ @Test
+ void demoRepositoryUriFailsClosedBeforeCloneOnStart() {
+ assertStartupFailure(
+ ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE,
+ "--spring.cloud.config.server.git.uri=https://github.com/your-repo/config-repo.git",
+ "--spring.cloud.config.server.git.clone-on-start=true"
+ );
+ }
+
+ @Test
+ void unsetRepositoryUriFailsClosedBeforeGitAccess() {
SpringApplication application = new SpringApplication(ConfigServerApplication.class);
application.setWebApplicationType(WebApplicationType.SERVLET);
ConfigurableApplicationContext context = null;
try {
- context = application.run(
- "--server.port=0",
- "--eureka.client.enabled=false",
- "--eureka.client.register-with-eureka=false",
- "--eureka.client.fetch-registry=false",
- "--spring.cloud.config.server.git.uri=",
- "--spring.cloud.config.server.git.clone-on-start=false"
- );
- fail("Blank CONFIG_REPO_URI must stop Config Server before Git access");
+ context = application.run(commonArgs());
+ fail("Unset CONFIG_REPO_URI must stop Config Server before Git access");
} catch (Exception ex) {
assertTrue(
- containsAuthorityFailure(ex),
- () -> "Startup must name the missing repository authority, but failed with: " + ex
+ containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex),
+ () -> "Startup must fail closed without a repository URI, but failed with: " + ex
);
} finally {
- if (context != null) {
- context.close();
- }
+ closeQuietly(context);
}
}
@Test
- void unsetRepositoryUriFailsClosedBeforeGitAccess() {
+ void nativeCombinedWithProductionFailsClosed() {
+ assertStartupFailure(
+ ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE,
+ "--spring.profiles.active=native,prod",
+ "--spring.cloud.config.server.native.search-locations=classpath:/",
+ "--spring.cloud.config.server.git.uri=https://git.example.internal/config-repo.git"
+ );
+ }
+
+ private static void assertStartupFailure(String expectedMessage, String... extraArgs) {
SpringApplication application = new SpringApplication(ConfigServerApplication.class);
application.setWebApplicationType(WebApplicationType.SERVLET);
ConfigurableApplicationContext context = null;
try {
- context = application.run(
- "--server.port=0",
- "--eureka.client.enabled=false",
- "--eureka.client.register-with-eureka=false",
- "--eureka.client.fetch-registry=false"
- );
- fail("Unset CONFIG_REPO_URI must stop Config Server before Git access");
+ context = application.run(concat(commonArgs(), extraArgs));
+ fail("Config Server must stop before Git access: " + expectedMessage);
} catch (Exception ex) {
assertTrue(
- containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex),
- () -> "Startup must fail closed without a repository URI, but failed with: " + ex
+ messageChain(ex).contains(expectedMessage),
+ () -> "Startup must name the missing repository authority, but failed with: " + ex
);
} finally {
- if (context != null) {
- context.close();
- }
+ closeQuietly(context);
}
}
+ private static String[] commonArgs() {
+ return new String[] {
+ "--server.port=0",
+ "--eureka.client.enabled=false",
+ "--eureka.client.register-with-eureka=false",
+ "--eureka.client.fetch-registry=false"
+ };
+ }
+
+ private static String[] concat(String[] first, String[] second) {
+ String[] merged = new String[first.length + second.length];
+ System.arraycopy(first, 0, merged, 0, first.length);
+ System.arraycopy(second, 0, merged, first.length, second.length);
+ return merged;
+ }
+
private static boolean containsAuthorityFailure(Throwable thrown) {
- return messageChain(thrown).contains("CONFIG_REPO_URI must name a deployment-owned Git repository");
+ return messageChain(thrown).contains(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE);
}
private static boolean containsUnresolvedPlaceholder(Throwable thrown) {
@@ -91,4 +119,10 @@ private static String messageChain(Throwable thrown) {
}
return messages.toString();
}
+
+ private static void closeQuietly(ConfigurableApplicationContext context) {
+ if (context != null) {
+ context.close();
+ }
+ }
}
diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java
index 15616502..417cf46c 100644
--- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java
+++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java
@@ -28,25 +28,85 @@ void rejectsMissingBlankUnresolvedAndDemoRemotes() {
IllegalStateException.class,
() -> ConfigServerRepositoryAuthority.requireExplicitRepository(" ")
);
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository("\u00A0\u200B")
+ );
assertThrows(
IllegalStateException.class,
() -> ConfigServerRepositoryAuthority.requireExplicitRepository("${CONFIG_REPO_URI}")
);
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository("${CONFIG_REPO_URI:}")
+ );
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "${CONFIG_REPO_URI:https://evil.example/config.git}"
+ )
+ );
assertThrows(
IllegalStateException.class,
() -> ConfigServerRepositoryAuthority.requireExplicitRepository(
"https://github.com/your-repo/config-repo.git"
)
);
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "https://github.com/your-repo/config-repo"
+ )
+ );
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "HTTPS://GITHUB.COM/YOUR-REPO/CONFIG-REPO.GIT"
+ )
+ );
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "git@github.com:your-repo/config-repo.git"
+ )
+ );
+ }
+
+ @Test
+ void rejectsRequestTemplatedGitLocations() {
+ assertEquals(
+ ConfigServerRepositoryAuthority.REQUEST_TEMPLATE_MESSAGE,
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "https://github.com/{application}/config.git"
+ )
+ ).getMessage()
+ );
+ assertThrows(
+ IllegalStateException.class,
+ () -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "https://git.example.internal/{profile}/{label}.git"
+ )
+ );
}
@Test
- void acceptsExplicitHttpsAndFileAuthorities() {
+ void acceptsExplicitHttpsSshFileAndNonDemoNestedPaths() {
assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository(
"https://git.example.internal/config-repo.git"
));
+ assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ " https://git.example.internal/config-repo.git "
+ ));
+ assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "ssh://git@git.example.internal/config-repo.git"
+ ));
assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository(
"file:/opt/reviewed-config-repo"
));
+ assertDoesNotThrow(() -> ConfigServerRepositoryAuthority.requireExplicitRepository(
+ "https://github.com/acme/your-repo/config-repo.git"
+ ));
}
}
diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java
index 9646fa38..227189bb 100644
--- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java
+++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java
@@ -5,6 +5,7 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -19,16 +20,18 @@ void configRepositoryMustBeExplicitAndHaveNoDemoFallback() throws IOException {
String applicationYaml = Files.readString(Path.of("src/main/resources/application.yml"));
assertTrue(
- applicationYaml.contains("uri: ${CONFIG_REPO_URI}"),
- "Config Server must require an operator-supplied CONFIG_REPO_URI"
+ Pattern.compile("(?m)^\\s*uri:\\s*\\$\\{CONFIG_REPO_URI\\}\\s*$")
+ .matcher(applicationYaml)
+ .find(),
+ "Config Server must require an exact operator-supplied CONFIG_REPO_URI token"
);
assertFalse(
- applicationYaml.contains("your-repo/config-repo.git"),
- "A demo Git repository must never be a supported runtime fallback"
+ applicationYaml.contains("${CONFIG_REPO_URI:"),
+ "Missing repository authority must not carry a YAML default colon"
);
assertFalse(
- applicationYaml.contains("CONFIG_REPO_URI:https://"),
- "Missing repository authority must fail closed before remote Git access"
+ applicationYaml.contains("your-repo/config-repo.git"),
+ "A demo Git repository must never be a supported runtime fallback"
);
}
@@ -49,6 +52,8 @@ void repositoryAuthorityHasSourceBackedDoctoring() throws IOException {
assertTrue(doctoring.contains("cloneOnStart"));
assertTrue(doctoring.contains("skipSslValidation"));
assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityValidator"));
+ assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityEnvironmentPostProcessor"));
+ assertTrue(doctoring.contains("xtrmetl.config.allow-native"));
assertTrue(doctoring.contains("blank"));
assertTrue(doctoring.contains("https://docs.spring.io/spring-cloud-config/reference/server/environment-repository/git-backend.html"));
assertTrue(doctoring.contains("https://docs.spring.io/spring-cloud-config/reference/server/security.html"));
diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md
index 59511ce6..594c1382 100644
--- a/docs/doctoring/config-server-repository-authority.md
+++ b/docs/doctoring/config-server-repository-authority.md
@@ -28,7 +28,7 @@ Rejected alternatives:
- treat repository credentials as proof that a destination is authorized;
- treat a YAML `String.contains` test as live fail-closed evidence.
-The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and adds `ConfigServerRepositoryAuthorityValidator` on every non-`native` profile. That bean rejects blank, unresolved `${CONFIG_REPO_URI...}`, and the retired demo remote before JGit can run. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls.
+The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` after config-data load. That processor rejects blank, Unicode-padded blank, unresolved `${CONFIG_REPO_URI...}`, request-templated `{application}` / `{profile}` / `{label}` locations, and the retired `github.com/your-repo/config-repo` destination (any case, with or without `.git`, HTTPS or `git@`) before JGit `afterPropertiesSet`. `ConfigServerRepositoryAuthorityValidator` remains as defense in depth on every non-`native` profile. Combining `native` with `prod` or `production` fails unless `xtrmetl.config.allow-native=true`. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls.
## Spring Cloud Config 5.0.4 contract
@@ -44,7 +44,7 @@ Git credentials are deployment secrets, not repository authority. They must be e
A missing or blank `CONFIG_REPO_URI` on the default profile is a deployment-configuration failure. The process must stop with a finite message that names `CONFIG_REPO_URI`. That failure is safer and more actionable than contacting an invented external repository or serving misleading configuration from an empty URI.
-The `native` profile remains independently startable without `CONFIG_REPO_URI` so inbound-security fixtures and local filesystem backends keep working. Do not use `native` to bypass destination authority in a composed production topology.
+The `native` profile remains independently startable without `CONFIG_REPO_URI` so inbound-security fixtures and local filesystem backends keep working. Do not use `native` to bypass destination authority in a composed production topology. A `native,prod` or `native,production` mix is a configuration failure unless an operator sets `xtrmetl.config.allow-native=true` for an approved fixture.
Repository URLs and provider exceptions can expose internal hostnames, usernames, paths, query parameters, or credentials. Ordinary observability should retain finite configuration and fetch outcome classifications rather than raw credential-bearing URLs or unrestricted exception text. This is purpose-bound diagnostic minimization, not blanket PII masking.
@@ -58,7 +58,7 @@ No central orchestrator, gateway, or sibling service acquires authority to rewri
## Evidence and replacement lineage
-`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile and requires startup failure for unset and blank `CONFIG_REPO_URI`. `ConfigServerRepositoryAuthorityTest` and `ConfigServerRepositoryAuthorityValidatorTest` cover null, whitespace, unresolved placeholder, demo remote, and explicit `https` / `file:` values. `ConfigServerRepositoryConfigurationTest` keeps the YAML token and doctoring contract from PR #189 / #322. Old PR #189 must not merge separately after this unique work is accepted.
+`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile. Blank and retired-demo URIs with `clone-on-start=true` must fail with the authority message, which is the evidence that the processor ran before JGit clone. Unset `CONFIG_REPO_URI` must fail with that message or Spring's unresolved-placeholder failure. `native,prod` must fail with the native/production message. `ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest` covers blank, demo, trim, native skip, and native+prod opt-in. `ConfigServerRepositoryAuthorityTest` covers null, ASCII blank, NBSP/ZWSP, `${CONFIG_REPO_URI:}` defaults, case and no-`.git` demo variants, `git@` demo, request templates, padded `https`, `ssh`, `file:`, and a non-demo nested path. `ConfigServerRepositoryConfigurationTest` pins the exact YAML token with no default colon. Old PR #189 / #322 / #327 must not merge separately after this unique work is accepted.
Canonical PRD, TRD, Architecture, UML, Security, Threat Model, Operability, and Traceability must represent this capability as `active_pr` until protected integration. The documentation must not infer that Config Server became a shipped default component merely because repository authority was hardened.
@@ -70,4 +70,8 @@ Spring Cloud Config. (2026). *Config Server (Spring Cloud Config 5.0.4)*. https:
Spring Cloud Config. (2026). *Git backend (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server/environment-repository/git-backend.html
+Spring Boot. (2026). *EnvironmentPostProcessor*. https://docs.spring.io/spring-boot/api/java/org/springframework/boot/env/EnvironmentPostProcessor.html
+
+Spring Boot. (2026). *SpringApplication*. https://docs.spring.io/spring-boot/reference/features/spring-application.html
+
Spring Cloud Config. (2026). *Security (Spring Cloud Config 5.0.4)*. https://docs.spring.io/spring-cloud-config/reference/server/security.html
From f9df0cf76d06b465015bbbb00261389eda41d0cb Mon Sep 17 00:00:00 2001
From: Cursor Agent
Operators: set {@code CONFIG_REPO_URI} to the reviewed Git URI, then start - * the default profile. Use the {@code native} profile only for local fixtures - * that must not depend on a remote. Do not combine {@code native} with - * {@code prod} or {@code production} unless {@code xtrmetl.config.allow-native=true} - * is an approved fixture exception.
+ * the default profile. Use the {@code native} profile only as the sole active + * profile for local fixtures that must not depend on a remote. */ public final class ConfigServerRepositoryAuthority { @@ -26,6 +24,9 @@ public final class ConfigServerRepositoryAuthority { "CONFIG_REPO_URI must name a deployment-owned Git repository; " + "blank, unresolved, or demo authority is rejected before remote Git access"; + static final String MIXED_NATIVE_PROFILE_MESSAGE = + "native profile must be the only active profile when Config Server uses local fixtures"; + static final String REQUEST_TEMPLATE_MESSAGE = "CONFIG_REPO_URI must be a concrete repository destination; " + "{application}, {profile}, and {label} placeholders are rejected"; @@ -65,6 +66,32 @@ public static void requireExplicitRepository(String repositoryUri) { } } + /** + * Ensures the local-fixture {@code native} profile cannot be composed with + * another active profile to skip Git repository authority validation. + * + * @param activeProfiles explicitly active Spring profiles + * @return {@code true} when standalone native mode is active + * @throws IllegalStateException when native is combined with another profile + */ + public static boolean requireSafeProfileComposition(String... activeProfiles) { + boolean nativeActive = false; + int activeCount = 0; + for (String profile : activeProfiles) { + if (profile == null || profile.isBlank()) { + continue; + } + activeCount++; + if ("native".equalsIgnoreCase(profile.trim())) { + nativeActive = true; + } + } + if (nativeActive && activeCount != 1) { + throw new IllegalStateException(MIXED_NATIVE_PROFILE_MESSAGE); + } + return nativeActive; + } + /** * Removes leading and trailing Unicode space and format padding. * diff --git a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java index bf09ac6b..5f259848 100644 --- a/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java +++ b/config-server/src/main/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessor.java @@ -5,9 +5,7 @@ import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.core.annotation.Order; import org.springframework.core.env.ConfigurableEnvironment; -import org.springframework.core.env.Environment; import org.springframework.core.env.MapPropertySource; -import org.springframework.core.env.Profiles; import java.util.Map; @@ -21,20 +19,14 @@ * processor runs after config-data load and before context refresh. * *Operators: set {@code CONFIG_REPO_URI} to a reviewed Git URI, then start - * the default profile. The {@code native} profile skips the Git URI check so - * local fixtures can start. Combining {@code native} with {@code prod} or - * {@code production} requires {@code xtrmetl.config.allow-native=true}.
+ * the default profile. The {@code native} profile skips the Git URI check only + * when it is the sole active profile. */ @Order(ConfigDataEnvironmentPostProcessor.ORDER + 1) public class ConfigServerRepositoryAuthorityEnvironmentPostProcessor implements EnvironmentPostProcessor { static final String PROPERTY_SOURCE_NAME = "config-server-repository-authority"; static final String GIT_URI_PROPERTY = "spring.cloud.config.server.git.uri"; - static final String ALLOW_NATIVE_PROPERTY = "xtrmetl.config.allow-native"; - static final String NATIVE_PRODUCTION_MESSAGE = - "native profile cannot be combined with prod or production; " - + "set CONFIG_REPO_URI and start the default Git profile, " - + "or set xtrmetl.config.allow-native=true only for an approved fixture"; /** * Fails closed on incompatible profiles or missing Git authority. @@ -44,15 +36,17 @@ public class ConfigServerRepositoryAuthorityEnvironmentPostProcessor implements */ @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { - requireCompatibleProfiles(environment); - if (environment.acceptsProfiles(Profiles.of("native"))) { + if (ConfigServerRepositoryAuthority.requireSafeProfileComposition(environment.getActiveProfiles())) { return; } String repositoryUri; try { repositoryUri = environment.getProperty(GIT_URI_PROPERTY); - } catch (IllegalArgumentException ex) { - throw new IllegalStateException(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, ex); + } catch (IllegalArgumentException unresolvedPlaceholder) { + throw new IllegalStateException( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + unresolvedPlaceholder + ); } ConfigServerRepositoryAuthority.requireExplicitRepository(repositoryUri); String trimmed = ConfigServerRepositoryAuthority.trimAuthority(repositoryUri); @@ -63,20 +57,4 @@ public void postProcessEnvironment(ConfigurableEnvironment environment, SpringAp )); } } - - /** - * Keeps {@code native} fixture-only unless an operator opts in. - * - * @param environment active Spring environment - */ - static void requireCompatibleProfiles(Environment environment) { - boolean nativeProfile = environment.acceptsProfiles(Profiles.of("native")); - boolean production = environment.acceptsProfiles(Profiles.of("prod", "production")); - boolean allowNative = Boolean.parseBoolean( - environment.getProperty(ALLOW_NATIVE_PROPERTY, "false") - ); - if (nativeProfile && production && !allowNative) { - throw new IllegalStateException(NATIVE_PRODUCTION_MESSAGE); - } - } } diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java index 1b3353f4..949def00 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest.java @@ -62,24 +62,39 @@ void nativeProfileSkipsGitUriCheck() { } @Test - void nativeCombinedWithProductionFailsUnlessExplicitlyAllowed() { - MockEnvironment blocked = new MockEnvironment(); - blocked.setActiveProfiles("native", "prod"); - blocked.setProperty( + void nativeCombinedWithAnotherProfileFailsClosed() { + MockEnvironment mixed = new MockEnvironment(); + mixed.setActiveProfiles("native", "default"); + mixed.setProperty( "spring.cloud.config.server.git.uri", "https://git.example.internal/config-repo.git" ); assertEquals( - ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, assertThrows( IllegalStateException.class, - () -> processor.postProcessEnvironment(blocked, application) + () -> processor.postProcessEnvironment(mixed, application) ).getMessage() ); + } - MockEnvironment allowed = new MockEnvironment(); - allowed.setActiveProfiles("native", "production"); - allowed.setProperty("xtrmetl.config.allow-native", "true"); - assertDoesNotThrow(() -> processor.postProcessEnvironment(allowed, application)); + @Test + void unresolvedPlaceholderIsRewrittenToAuthorityFailure() { + MockEnvironment environment = new MockEnvironment() { + @Override + public String getProperty(String key) { + if ("spring.cloud.config.server.git.uri".equals(key)) { + throw new IllegalArgumentException("Could not resolve placeholder 'CONFIG_REPO_URI'"); + } + return super.getProperty(key); + } + }; + assertEquals( + ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> processor.postProcessEnvironment(environment, application) + ).getMessage() + ); } } diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java index 35dfdd66..e863dad9 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityLiveTest.java @@ -4,85 +4,91 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.WebApplicationType; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.StandardEnvironment; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; /** - * Starts the default Git-backed Config Server and proves blank or demo - * repository authority cannot become a running process, including when - * {@code cloneOnStart} is true. + * Starts the Config Server and proves repository/profile authority fails closed + * before a Git backend can become active, including when {@code cloneOnStart} + * is true. * *Operators: set {@code CONFIG_REPO_URI} to a deployment-owned Git URI - * before starting the default profile. Use {@code native} only for reviewed - * local fixtures that do not need a remote.
+ * before starting the default profile. Use {@code native} only as the sole + * active profile for reviewed local fixtures that do not need a remote. */ class ConfigServerRepositoryAuthorityLiveTest { @Test void blankRepositoryUriFailsClosedWithAuthorityMessage() { - assertStartupFailure( - ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, - "--spring.cloud.config.server.git.uri=", - "--spring.cloud.config.server.git.clone-on-start=true" + assertTrue( + containsAuthorityFailure(runExpectingFailure( + "--spring.cloud.config.server.git.uri=", + "--spring.cloud.config.server.git.clone-on-start=true" + )), + "Blank CONFIG_REPO_URI must fail with the repository-authority message" ); } @Test void demoRepositoryUriFailsClosedBeforeCloneOnStart() { - assertStartupFailure( - ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE, - "--spring.cloud.config.server.git.uri=https://github.com/your-repo/config-repo.git", - "--spring.cloud.config.server.git.clone-on-start=true" + assertTrue( + containsAuthorityFailure(runExpectingFailure( + "--spring.cloud.config.server.git.uri=https://github.com/your-repo/config-repo.git", + "--spring.cloud.config.server.git.clone-on-start=true" + )), + "Retired demo URI must be rejected before JGit clone-on-start" ); } @Test - void unsetRepositoryUriFailsClosedBeforeGitAccess() { - SpringApplication application = new SpringApplication(ConfigServerApplication.class); - application.setWebApplicationType(WebApplicationType.SERVLET); - ConfigurableApplicationContext context = null; - try { - context = application.run(commonArgs()); - fail("Unset CONFIG_REPO_URI must stop Config Server before Git access"); - } catch (Exception ex) { - assertTrue( - containsAuthorityFailure(ex) || containsUnresolvedPlaceholder(ex), - () -> "Startup must fail closed without a repository URI, but failed with: " + ex - ); - } finally { - closeQuietly(context); - } + void unsetRepositoryUriFailsClosedWithoutInheritedEnvironmentAuthority() { + assertTrue( + containsAuthorityFailure(runExpectingFailure()), + "Unset CONFIG_REPO_URI must fail with the repository-authority message" + ); } @Test - void nativeCombinedWithProductionFailsClosed() { - assertStartupFailure( - ConfigServerRepositoryAuthorityEnvironmentPostProcessor.NATIVE_PRODUCTION_MESSAGE, - "--spring.profiles.active=native,prod", + void nativeProfileCannotBeCombinedWithAnotherActiveProfile() { + Exception failure = runExpectingFailure( + "--spring.profiles.active=native,default", "--spring.cloud.config.server.native.search-locations=classpath:/", "--spring.cloud.config.server.git.uri=https://git.example.internal/config-repo.git" ); + assertTrue( + messageChain(failure).contains(ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE), + () -> "Mixed native profile startup must fail closed: " + failure + ); } - private static void assertStartupFailure(String expectedMessage, String... extraArgs) { - SpringApplication application = new SpringApplication(ConfigServerApplication.class); - application.setWebApplicationType(WebApplicationType.SERVLET); + private static Exception runExpectingFailure(String... extraArgs) { + SpringApplication application = applicationWithoutInheritedConfigRepoUri(); ConfigurableApplicationContext context = null; try { context = application.run(concat(commonArgs(), extraArgs)); - fail("Config Server must stop before Git access: " + expectedMessage); + fail("Config Server startup was expected to fail closed"); + throw new AssertionError("unreachable"); } catch (Exception ex) { - assertTrue( - messageChain(ex).contains(expectedMessage), - () -> "Startup must name the missing repository authority, but failed with: " + ex - ); + return ex; } finally { - closeQuietly(context); + if (context != null) { + context.close(); + } } } + private static SpringApplication applicationWithoutInheritedConfigRepoUri() { + SpringApplication application = new SpringApplication(ConfigServerApplication.class); + application.setWebApplicationType(WebApplicationType.SERVLET); + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().remove(StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME); + application.setEnvironment(environment); + return application; + } + private static String[] commonArgs() { return new String[] { "--server.port=0", @@ -103,12 +109,6 @@ private static boolean containsAuthorityFailure(Throwable thrown) { return messageChain(thrown).contains(ConfigServerRepositoryAuthority.MISSING_AUTHORITY_MESSAGE); } - private static boolean containsUnresolvedPlaceholder(Throwable thrown) { - String messages = messageChain(thrown); - return messages.contains("Could not resolve placeholder 'CONFIG_REPO_URI'") - || messages.contains("Could not resolve placeholder 'spring.cloud.config.server.git.uri'"); - } - private static String messageChain(Throwable thrown) { assertNotNull(thrown); StringBuilder messages = new StringBuilder(); @@ -119,10 +119,4 @@ private static String messageChain(Throwable thrown) { } return messages.toString(); } - - private static void closeQuietly(ConfigurableApplicationContext context) { - if (context != null) { - context.close(); - } - } } diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java index 417cf46c..d21fd93e 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryAuthorityTest.java @@ -70,6 +70,38 @@ void rejectsMissingBlankUnresolvedAndDemoRemotes() { "git@github.com:your-repo/config-repo.git" ) ); + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireExplicitRepository( + "ssh://git@github.com/your-repo/config-repo.git" + ) + ); + } + + @Test + void nativeMustBeTheSoleActiveProfile() { + assertEquals( + true, + ConfigServerRepositoryAuthority.requireSafeProfileComposition("native") + ); + assertEquals( + false, + ConfigServerRepositoryAuthority.requireSafeProfileComposition() + ); + assertEquals( + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireSafeProfileComposition("native", "default") + ).getMessage() + ); + assertEquals( + ConfigServerRepositoryAuthority.MIXED_NATIVE_PROFILE_MESSAGE, + assertThrows( + IllegalStateException.class, + () -> ConfigServerRepositoryAuthority.requireSafeProfileComposition("native", "prod") + ).getMessage() + ); } @Test diff --git a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java index 227189bb..57bf4e2b 100644 --- a/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java +++ b/config-server/src/test/java/com/xtrmetl/config/ConfigServerRepositoryConfigurationTest.java @@ -35,6 +35,19 @@ void configRepositoryMustBeExplicitAndHaveNoDemoFallback() throws IOException { ); } + @Test + void boot35DiscoversTheProcessorThroughSpringFactories() throws IOException { + String factories = Files.readString(Path.of( + "src/main/resources/META-INF/spring.factories" + )); + assertTrue( + factories.contains( + "com.xtrmetl.config.ConfigServerRepositoryAuthorityEnvironmentPostProcessor" + ), + "Boot 3.5.16 EnvironmentPostProcessor discovery uses META-INF/spring.factories" + ); + } + @Test void repositoryAuthorityHasSourceBackedDoctoring() throws IOException { Path doctoringPath = Path.of( @@ -53,7 +66,9 @@ void repositoryAuthorityHasSourceBackedDoctoring() throws IOException { assertTrue(doctoring.contains("skipSslValidation")); assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityValidator")); assertTrue(doctoring.contains("ConfigServerRepositoryAuthorityEnvironmentPostProcessor")); - assertTrue(doctoring.contains("xtrmetl.config.allow-native")); + assertTrue(doctoring.contains("META-INF/spring.factories")); + assertTrue(doctoring.contains("native profile must be the only active profile")); + assertTrue(doctoring.contains("unset or blank CONFIG_REPO_URI")); assertTrue(doctoring.contains("blank")); assertTrue(doctoring.contains("https://docs.spring.io/spring-cloud-config/reference/server/environment-repository/git-backend.html")); assertTrue(doctoring.contains("https://docs.spring.io/spring-cloud-config/reference/server/security.html")); diff --git a/docs/doctoring/config-server-repository-authority.md b/docs/doctoring/config-server-repository-authority.md index b32ce032..1a89547a 100644 --- a/docs/doctoring/config-server-repository-authority.md +++ b/docs/doctoring/config-server-repository-authority.md @@ -7,11 +7,9 @@ ```mermaid flowchart TD start[Start Config Server] --> native{native profile active?} - native -->|yes| prod{prod or production also active?} - prod -->|yes| allow{xtrmetl.config.allow-native=true?} - allow -->|no| failNative[Stop. Export CONFIG_REPO_URI and start the default Git profile, or set allow-native only for an approved fixture] - allow -->|yes| fixtures[Continue native fixtures] - prod -->|no| fixtures + native -->|yes| mixed{any other profile also active?} + mixed -->|yes| failNative[Stop. native must be the only active profile for local fixtures] + mixed -->|no| fixtures[Continue native fixtures] native -->|no| uri{CONFIG_REPO_URI a reviewed concrete Git destination?} uri -->|blank, unresolved, demo, or template| failUri[Stop. Export CONFIG_REPO_URI to the reviewed repository, then start again] uri -->|yes| git[Bind the Git backend] @@ -23,7 +21,7 @@ mightyETL's independently runnable Config Server Git backend must have explicit This is a narrow destination-authority decision, not a claim that Config Server is part of the default supported topology. The protected Compose profile does not establish Config Server as a required production dependency. A later product decision may promote, constrain, or retire the module, but none of those outcomes requires retaining a fake repository fallback. -Operators: export `CONFIG_REPO_URI` to the reviewed Git URI, then start the default profile. Use the `native` profile only for local fixtures that must not depend on a remote. Do not start the default profile with a blank secret. +Operators: export `CONFIG_REPO_URI` to the reviewed Git URI, then start the default profile. Use the `native` profile only as the sole active profile for local fixtures that must not depend on a remote. Do not start the default profile with an unset or blank `CONFIG_REPO_URI`. Git credentials are a separate secret. ## Root cause and rejected alternatives @@ -41,7 +39,7 @@ Rejected alternatives: - treat repository credentials as proof that a destination is authorized; - treat a YAML `String.contains` test as live fail-closed evidence. -The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` after config-data load. That processor rejects blank, Unicode-padded blank, unresolved `${CONFIG_REPO_URI...}`, request-templated `{application}` / `{profile}` / `{label}` locations, and the retired `github.com/your-repo/config-repo` destination (any case, with or without `.git`, HTTPS or `git@`) before JGit `afterPropertiesSet`. `ConfigServerRepositoryAuthorityValidator` remains as defense in depth on every non-`native` profile. Combining `native` with `prod` or `production` fails unless `xtrmetl.config.allow-native=true`. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls. +The selected remedy keeps the explicit `${CONFIG_REPO_URI}` token and registers `ConfigServerRepositoryAuthorityEnvironmentPostProcessor` in `META-INF/spring.factories` after config-data load. Boot 3.5.16 discovers that processor only through `spring.factories`; the imports-style file is retained for later Boot 4 discovery and is not the Boot 3.5 control. The processor rejects blank, Unicode-padded blank, unresolved `${CONFIG_REPO_URI...}`, request-templated `{application}` / `{profile}` / `{label}` locations, and the retired `github.com/your-repo/config-repo` destination (any case, with or without `.git`, HTTPS, `ssh://`, or `git@`) before JGit `afterPropertiesSet`. `ConfigServerRepositoryAuthorityValidator` remains as defense in depth on every non-`native` profile. Combining `native` with any other active profile fails closed; the `native` profile must be the only active profile. Config Server HTTP authentication, Git credentials, trust material, retry and timeout policy, readiness, repository support status, and service-to-service identity remain separate controls. ## Spring Cloud Config 5.0.4 contract @@ -55,9 +53,9 @@ Git credentials are deployment secrets, not repository authority. They must be e ## Failure, privacy, and operability semantics -A missing or blank `CONFIG_REPO_URI` on the default profile is a deployment-configuration failure. The process must stop with a finite message that names `CONFIG_REPO_URI`. That failure is safer and more actionable than contacting an invented external repository or serving misleading configuration from an empty URI. +A missing or blank `CONFIG_REPO_URI` on the default profile is a deployment-configuration failure. The process must stop with a finite message that names `CONFIG_REPO_URI`. An unset or blank CONFIG_REPO_URI is rejected before remote Git access. That failure is safer and more actionable than contacting an invented external repository or serving misleading configuration from an empty URI. -The `native` profile remains independently startable without `CONFIG_REPO_URI` so inbound-security fixtures and local filesystem backends keep working. Do not use `native` to bypass destination authority in a composed production topology. A `native,prod` or `native,production` mix is a configuration failure unless an operator sets `xtrmetl.config.allow-native=true` for an approved fixture. +The `native` profile remains independently startable without `CONFIG_REPO_URI` so inbound-security fixtures and local filesystem backends keep working. Do not use `native` to bypass destination authority in a composed production topology. A mixed native composition fails with: native profile must be the only active profile when Config Server uses local fixtures. Repository URLs and provider exceptions can expose internal hostnames, usernames, paths, query parameters, or credentials. Ordinary observability should retain finite configuration and fetch outcome classifications rather than raw credential-bearing URLs or unrestricted exception text. This is purpose-bound diagnostic minimization, not blanket PII masking. @@ -71,7 +69,7 @@ No central orchestrator, gateway, or sibling service acquires authority to rewri ## Evidence and replacement lineage -`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile. Blank and retired-demo URIs with `clone-on-start=true` must fail with the authority message, which is the evidence that the processor ran before JGit clone. Unset `CONFIG_REPO_URI` must fail with that message or Spring's unresolved-placeholder failure. `native,prod` must fail with the native/production message. `ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest` covers blank, demo, trim, native skip, and native+prod opt-in. `ConfigServerRepositoryAuthorityTest` covers null, ASCII blank, NBSP/ZWSP, `${CONFIG_REPO_URI:}` defaults, case and no-`.git` demo variants, `git@` demo, request templates, padded `https`, `ssh`, `file:`, and a non-demo nested path. `ConfigServerRepositoryConfigurationTest` pins the exact YAML token with no default colon. Old PR #189 / #322 / #327 must not merge separately after this unique work is accepted. +`ConfigServerRepositoryAuthorityLiveTest` starts `ConfigServerApplication` on the default servlet profile after removing inherited `systemEnvironment`, so a runner-level `CONFIG_REPO_URI` cannot satisfy the unset case. Blank and retired-demo URIs with `clone-on-start=true` must fail with the authority message, which is the evidence that the processor ran before JGit clone. Unset `CONFIG_REPO_URI` must fail with that same finite authority message, not an unrelated placeholder error. `native,default` must fail because the `native` profile must be the only active profile. `ConfigServerRepositoryAuthorityEnvironmentPostProcessorTest` covers blank, demo, trim, native skip, mixed native, and unresolved-placeholder rewrite. `ConfigServerRepositoryAuthorityTest` covers null, ASCII blank, NBSP/ZWSP, `${CONFIG_REPO_URI:}` defaults, case and no-`.git` demo variants, `git@` and `ssh://` demo, request templates, padded `https`, `ssh`, `file:`, a non-demo nested path, and mixed native composition. `ConfigServerRepositoryConfigurationTest` pins the exact YAML token with no default colon and Boot 3.5 `META-INF/spring.factories` discovery. Old PR #189 / #322 / #327 must not merge separately after this unique work is accepted. Canonical PRD, TRD, Architecture, UML, Security, Threat Model, Operability, and Traceability must represent this capability as `active_pr` until protected integration. The documentation must not infer that Config Server became a shipped default component merely because repository authority was hardened. From b1fdb4a871fb1dbeb9cfb9ade44143e199d90104 Mon Sep 17 00:00:00 2001 From: Seongho Bae