diff --git a/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java b/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java index c8b73816..f04711ae 100644 --- a/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java +++ b/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java @@ -12,10 +12,25 @@ import javax.sql.DataSource; +/** + * Configures the JDBC client used to apply CDC changes to an optional PostgreSQL replica. + * + *
Replica endpoint components are validated before the JDBC URL is constructed. Rejected + * configuration values are classified by key without being copied into exception messages, so + * credential-like or control-character-bearing input cannot be republished through startup + * diagnostics.
+ */ @Configuration @ConditionalOnProperty(prefix = "xtrmetl.replica", name = "enabled", havingValue = "true") public class ReplicaJdbcTemplateConfig { + /** + * Creates the replica connection pool from environment-backed CDC configuration. + * + * @param environment source of replica endpoint, credentials, and Hikari startup settings + * @return a Hikari data source configured for the replica PostgreSQL endpoint + * @throws IllegalStateException when a required endpoint value or initialization timeout is invalid + */ @Bean(name = "replicaDataSource", destroyMethod = "close") public HikariDataSource replicaDataSource(Environment environment) { String host = ValidationUtils.requireValidHost( @@ -42,11 +57,8 @@ public HikariDataSource replicaDataSource(Environment environment) { long initializationFailTimeout; try { initializationFailTimeout = Long.parseLong(initializationFailTimeoutValue); - } catch (NumberFormatException e) { - throw new IllegalStateException( - "Invalid value for REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS: " + initializationFailTimeoutValue, - e - ); + } catch (NumberFormatException ignored) { + throw new IllegalStateException("Invalid value for REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS"); } config.setInitializationFailTimeout(initializationFailTimeout); config.setDriverClassName("org.postgresql.Driver"); @@ -57,6 +69,12 @@ public HikariDataSource replicaDataSource(Environment environment) { return new HikariDataSource(config); } + /** + * Creates the JDBC template used by replica apply services. + * + * @param dataSource validated replica connection pool + * @return a JDBC template bound to the replica data source + */ @Bean(name = "replicaJdbcTemplate") public JdbcTemplate replicaJdbcTemplate(@Qualifier("replicaDataSource") DataSource dataSource) { return new JdbcTemplate(dataSource); diff --git a/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java b/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java index 5fe24347..b329820e 100644 --- a/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java +++ b/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java @@ -2,47 +2,78 @@ import org.springframework.lang.Nullable; +/** + * Validates externally supplied CDC configuration values before they are used by database clients. + * + *Rejected values are intentionally omitted from exception messages because configuration inputs + * can contain credentials, control characters, or other sensitive operator data. Diagnostics identify + * the configuration key and validation category without republishing the rejected value.
+ */ public final class ValidationUtils { private ValidationUtils() {} + /** + * Validates a TCP port supplied as configuration text. + * + * @param value configured port text + * @param key configuration key used for safe diagnostics + * @return the trimmed decimal port text + * @throws IllegalStateException when the value is missing, non-numeric, or outside 1 through 65535 + */ public static String requireValidPort(@Nullable String value, String key) { if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required port for " + key); } String trimmed = value.trim(); if (!trimmed.matches("^[0-9]+$")) { - throw new IllegalStateException("Invalid port for " + key + ": " + value); + throw new IllegalStateException("Invalid port for " + key); } try { int port = Integer.parseInt(trimmed); if (port < 1 || port > 65535) { - throw new IllegalStateException("Invalid port for " + key + ": " + value); + throw new IllegalStateException("Invalid port for " + key); } return trimmed; - } catch (NumberFormatException e) { - throw new IllegalStateException("Invalid port for " + key + ": " + value, e); + } catch (NumberFormatException ignored) { + throw new IllegalStateException("Invalid port for " + key); } } + /** + * Validates a hostname or IP-literal-shaped host token accepted by the CDC configuration contract. + * + * @param value configured host text + * @param key configuration key used for safe diagnostics + * @return the trimmed host text + * @throws IllegalStateException when the value is missing or contains unsupported characters + */ public static String requireValidHost(@Nullable String value, String key) { if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required host for " + key); } String trimmed = value.trim(); if (!trimmed.matches("^[a-zA-Z0-9._-]+$")) { - throw new IllegalStateException("Invalid host for " + key + ": " + value); + throw new IllegalStateException("Invalid host for " + key); } return trimmed; } + /** + * Validates a simple identifier used for CDC database and configuration names. + * + * @param value configured identifier text + * @param key configuration key used for safe diagnostics + * @return the trimmed identifier + * @throws IllegalStateException when the value is missing or contains unsupported characters + */ public static String requireValidIdentifier(@Nullable String value, String key) { if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required value for " + key); } String trimmed = value.trim(); if (!trimmed.matches("^[a-zA-Z0-9_-]+$")) { - throw new IllegalStateException("Invalid value for " + key + ": " + value); + throw new IllegalStateException("Invalid value for " + key); } return trimmed; } diff --git a/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java b/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java index 20eff496..e9d784f0 100644 --- a/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java +++ b/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java @@ -4,14 +4,17 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.env.MockEnvironment; import javax.sql.DataSource; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class ReplicaJdbcTemplateConfigTest { @@ -98,4 +101,64 @@ void failsWithHelpfulMessageWhenInitializationFailTimeoutIsNotANumber() { assertTrue(failure.getMessage().contains("REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS")); }); } + + @Test + void invalidInitializationFailTimeoutDiagnosticDoesNotRepublishRejectedValue() { + String rejectedValue = "not-a-number-Bearer-replica-secret"; + + contextRunner + .withPropertyValues( + "xtrmetl.replica.enabled=true", + "REPLICA_PGHOST=replica-host", + "REPLICA_PGDATABASE=xtrmetl", + "REPLICA_PGUSER=xtrmetl_user", + "REPLICA_PGPASSWORD=xtrmetl_password", + "REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS=" + rejectedValue + ) + .run(context -> { + Throwable failure = context.getStartupFailure(); + assertNotNull(failure); + + boolean keyWasReported = false; + for (Throwable current = failure; current != null; current = current.getCause()) { + String message = current.getMessage(); + if (message != null) { + keyWasReported |= message.contains("REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS"); + assertFalse(message.contains(rejectedValue)); + } + } + assertTrue(keyWasReported); + }); + } + + @Test + void invalidInitializationFailTimeoutControlCharactersDoNotReachExceptionChain() { + String rejectedValue = + "jdbc:postgresql://replica.internal/app?password=secret\r\nAuthorization: Bearer token"; + MockEnvironment environment = new MockEnvironment() + .withProperty("REPLICA_PGHOST", "replica-host") + .withProperty("REPLICA_PGDATABASE", "xtrmetl") + .withProperty("REPLICA_PGUSER", "xtrmetl_user") + .withProperty("REPLICA_PGPASSWORD", "xtrmetl_password") + .withProperty("REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS", rejectedValue); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> new ReplicaJdbcTemplateConfig().replicaDataSource(environment) + ); + assertNull(failure.getCause()); + + boolean keyWasReported = false; + for (Throwable current = failure; current != null; current = current.getCause()) { + String message = current.getMessage(); + if (message != null) { + keyWasReported |= message.contains("REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS"); + assertFalse(message.contains(rejectedValue)); + assertFalse(message.contains("Authorization: Bearer token")); + assertFalse(message.contains("\r")); + assertFalse(message.contains("\n")); + } + } + assertTrue(keyWasReported); + } } diff --git a/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java b/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java index 83681101..c7280a2a 100644 --- a/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java +++ b/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java @@ -3,7 +3,10 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class ValidationUtilsTest { @@ -54,4 +57,50 @@ void requireValidIdentifierThrowsWhenMissingOrInvalid() { assertThrows(IllegalStateException.class, () -> ValidationUtils.requireValidIdentifier("xtrmetl?evil", "REPLICA_PGDATABASE")); assertThrows(IllegalStateException.class, () -> ValidationUtils.requireValidIdentifier("xtrmetl/db", "REPLICA_PGDATABASE")); } + + @Test + void invalidConfigurationDiagnosticsDoNotRepublishRejectedValues() { + String sensitiveFragment = "password=secret-8472"; + + IllegalStateException hostFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidHost( + "replica-host?" + sensitiveFragment + "\r\nforged-log-line", + "REPLICA_PGHOST" + ) + ); + IllegalStateException portFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidPort("5432?" + sensitiveFragment, "REPLICA_PGPORT") + ); + IllegalStateException oversizedPortFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidPort("999999999999999999999999999999999999", "REPLICA_PGPORT") + ); + IllegalStateException identifierFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidIdentifier( + "customer_db?" + sensitiveFragment, + "REPLICA_PGDATABASE" + ) + ); + + assertSafeDiagnostic(hostFailure, "REPLICA_PGHOST", sensitiveFragment); + assertSafeDiagnostic(portFailure, "REPLICA_PGPORT", sensitiveFragment); + assertSafeDiagnostic(oversizedPortFailure, "REPLICA_PGPORT", sensitiveFragment); + assertNull(oversizedPortFailure.getCause()); + assertSafeDiagnostic(identifierFailure, "REPLICA_PGDATABASE", sensitiveFragment); + } + + private static void assertSafeDiagnostic( + IllegalStateException failure, + String expectedKey, + String sensitiveFragment + ) { + assertTrue(failure.getMessage().contains(expectedKey)); + assertFalse(failure.getMessage().contains(sensitiveFragment)); + assertFalse(failure.getMessage().contains("forged-log-line")); + assertFalse(failure.getMessage().contains("\r")); + assertFalse(failure.getMessage().contains("\n")); + } }