Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,25 @@

import javax.sql.DataSource;

/**
* Configures the JDBC client used to apply CDC changes to an optional PostgreSQL replica.
*
* <p>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.</p>
*/
@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(
Expand All @@ -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");
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,78 @@

import org.springframework.lang.Nullable;

/**
* Validates externally supplied CDC configuration values before they are used by database clients.
*
* <p>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.</p>
*/
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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"));
}
}
Loading