Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Enumeration;
import java.util.Properties;
import java.util.logging.Logger;
import java.util.regex.Pattern;

import com.amazonaws.secretsmanager.caching.SecretCache;
import com.amazonaws.secretsmanager.caching.SecretCacheConfiguration;
Expand Down Expand Up @@ -108,6 +109,10 @@ public abstract class AWSSecretsManagerDriver implements Driver {
*/
public static final String INVALID_SECRET_STRING_JSON = "Could not parse SecretString JSON";

private static final Pattern INVALID_HOST_PATTERN = Pattern.compile(".*[?#&;/@\\\\\\s].*");
private static final Pattern INVALID_DBNAME_PATTERN = Pattern.compile(".*[?#&;\\\\\\s].*");
private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("\\d+");

private SecretCache secretCache;

private String realDriverClass;
Expand Down Expand Up @@ -377,6 +382,26 @@ private Connection connectWithSecret(String unwrappedUrl, Properties info, Strin
throw new SQLException("Connect failed to authenticate: reached max connection retries");
}

/**
* Validates that secret fields do not contain characters that could be used for URL injection.
* Prevents CWE-610 (Externally Controlled Reference to a Resource in Another Sphere).
*/
private void validateSecretFields(String endpoint, String port, String dbname) throws SQLException {
if (endpoint == null || endpoint.isEmpty()) {
throw new SQLException("Secret 'host' field must not be null or empty.");
}
if (INVALID_HOST_PATTERN.matcher(endpoint).matches()) {
throw new SQLException("Secret 'host' field contains invalid characters. "
+ "A valid host must be a hostname or IP address without URL special characters.");
}
if (port != null && !port.isEmpty() && !DIGITS_ONLY_PATTERN.matcher(port).matches()) {
throw new SQLException("Secret 'port' field must contain only digits.");
}
if (dbname != null && !dbname.isEmpty() && INVALID_DBNAME_PATTERN.matcher(dbname).matches()) {
throw new SQLException("Secret 'dbname' field contains invalid characters.");
}
}

@Override
@SuppressFBWarnings("THROWS_METHOD_THROWS_RUNTIMEEXCEPTION")
public Connection connect(String url, Properties info) throws SQLException {
Expand All @@ -400,6 +425,7 @@ public Connection connect(String url, Properties info) throws SQLException {
String port = portNode == null ? null : portNode.asText();
JsonNode dbnameNode = jsonObject.get("dbname");
String dbname = dbnameNode == null ? null : dbnameNode.asText();
validateSecretFields(endpoint, port, dbname);
unwrappedUrl = constructUrlFromEndpointPortDatabase(endpoint, port, dbname);
} catch (IOException e) {
// Most likely to occur in the event that the data is not JSON.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,108 @@ public void test_jdbcCompliant_propagatesToRealDriver() {
assertEquals(true, sut.jdbcCompliant());
assertEquals(1, DummyDriver.jdbcCompliantCallCount);
}

/*******************************************************************************************************************
* validateSecretFields Tests (URL Injection Prevention)
******************************************************************************************************************/

@Test
public void test_connect_throws_hostWithFragment() {
Mockito.when(cache.getSecretString("MALICIOUS_HOST")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"evil.com:3306/x?allowLoadLocalInfile=true#\", \"port\": \"3306\", \"dbname\": \"test\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("MALICIOUS_HOST", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_throws_hostWithQuestionMark() {
Mockito.when(cache.getSecretString("MALICIOUS_HOST_Q")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"evil.com?param=val\", \"port\": \"3306\", \"dbname\": \"test\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("MALICIOUS_HOST_Q", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_throws_hostWithSlash() {
Mockito.when(cache.getSecretString("MALICIOUS_HOST_SLASH")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"evil.com/path\", \"port\": \"3306\", \"dbname\": \"test\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("MALICIOUS_HOST_SLASH", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_throws_hostWithAtSign() {
Mockito.when(cache.getSecretString("MALICIOUS_HOST_AT")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"user@evil.com\", \"port\": \"3306\", \"dbname\": \"test\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("MALICIOUS_HOST_AT", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_throws_nonNumericPort() {
Mockito.when(cache.getSecretString("BAD_PORT")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"valid.host.com\", \"port\": \"3306;inject\", \"dbname\": \"test\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("BAD_PORT", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_throws_dbnameWithFragment() {
Mockito.when(cache.getSecretString("BAD_DBNAME")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"valid.host.com\", \"port\": \"3306\", \"dbname\": \"db#fragment\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("BAD_DBNAME", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_works_validHostPortDbname() {
Mockito.when(cache.getSecretString("VALID_SECRET")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"mydb.us-east-1.rds.amazonaws.com\", \"port\": \"5432\", \"dbname\": \"prod\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertNotThrows(() -> sut.connect("VALID_SECRET", props));
assertEquals(1, DummyDriver.connectCallCount);
}

@Test
public void test_connect_works_nullPortAndDbname() {
Mockito.when(cache.getSecretString("MINIMAL_SECRET")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"localhost\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertNotThrows(() -> sut.connect("MINIMAL_SECRET", props));
assertEquals(1, DummyDriver.connectCallCount);
}

@Test
public void test_connect_throws_emptyHost() {
Mockito.when(cache.getSecretString("EMPTY_HOST")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"\", \"port\": \"3306\", \"dbname\": \"test\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertThrows(SQLException.class, () -> sut.connect("EMPTY_HOST", props));
assertEquals(0, DummyDriver.connectCallCount);
}

@Test
public void test_connect_works_emptyPortAndDbname() {
Mockito.when(cache.getSecretString("EMPTY_OPTIONAL")).thenReturn(
"{\"username\": \"user\", \"password\": \"pass\", \"host\": \"valid.host.com\", \"port\": \"\", \"dbname\": \"\"}");
Properties props = new Properties();
props.setProperty("user", "user");
assertNotThrows(() -> sut.connect("EMPTY_OPTIONAL", props));
assertEquals(1, DummyDriver.connectCallCount);
}
}
Loading