This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new b4dd833ca4 [Cherry-pick to branch-1.3] [#12024] improvement(common):
harden JDBC unsafe-parameter detection against connectionProperties smuggling
(#12025) (#12185)
b4dd833ca4 is described below
commit b4dd833ca4686a95a0e270746083284760882f08
Author: YangJie <[email protected]>
AuthorDate: Sat Jul 25 00:45:16 2026 +0800
[Cherry-pick to branch-1.3] [#12024] improvement(common): harden JDBC
unsafe-parameter detection against connectionProperties smuggling (#12025)
(#12185)
### What changes were proposed in this pull request?
- Replace the config-**value** scan in `JdbcUrlUtils` with a
config-**name** scan: every config key, plus each parameter name
embedded in DBCP2's `connectionProperties` value.
- Parse `connectionProperties` exactly as DBCP2 does (`Properties.load`
after replacing `;`→`\n`); reject a malformed value fail-closed.
- Use `Locale.ROOT` for all case folding.
### Why are the changes needed?
`JdbcUrlUtils.validateJdbcConfig` guards against dangerous JDBC
parameters (e.g. MySQL `autoDeserialize`, an RCE enabler) but only
matched config *values* and never inspected DBCP2's
`connectionProperties`. An unsafe parameter could be smuggled to the
driver as a config key, or inside `connectionProperties` (reachable via
`gravitino.bypass.connectionProperties=autoDeserialize=true`, whose
prefix is stripped before the map reaches the datasource), fully
bypassing the check.
Fix: #12024
### Does this PR introduce _any_ user-facing change?
No. A configuration that was already unsafe is now rejected earlier with
a clear error. The exception message wording changes from "...detected
in JDBC URL" to "...detected in JDBC configuration" to reflect that
config keys/`connectionProperties` are inspected too, not only the URL.
### How was this patch tested?
New unit tests in `TestJdbcUrlUtils` (connectionProperties for
MySQL/MariaDB/PostgreSQL, config-key detection, newline / `\uXXXX` /
double-URL-encoding smuggling, whole-name-vs-substring and value-vs-name
negative controls, Turkish-locale folding, malformed-value rejection)
and an end-to-end `TestDataSourceUrlValidation` case through
`DataSourceUtils.createDataSource`. `./gradlew :common:test
:catalogs:catalog-jdbc-common:test -PskipITs` and spotless pass.
---
Cherry-pick of #12025 to `branch-1.3`; replaces the auto-generated
#12183 (its GitHub auto cherry-pick committed conflict markers). The
only manual resolution was in `TestDataSourceUrlValidation.java`, taking
#12025's strengthened cause-message assertions over the older type-only
`assertThrows` on `branch-1.3`.
---
.../jdbc/utils/TestDataSourceUrlValidation.java | 54 ++-
.../org/apache/gravitino/utils/JdbcUrlUtils.java | 83 ++++-
.../apache/gravitino/utils/TestJdbcUrlUtils.java | 402 ++++++++++++++++++++-
3 files changed, 517 insertions(+), 22 deletions(-)
diff --git
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
index e05e4f1062..21cdba0984 100644
---
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
+++
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/utils/TestDataSourceUrlValidation.java
@@ -53,8 +53,16 @@ public class TestDataSourceUrlValidation {
properties.put(JdbcConfig.USERNAME.getKey(), "test");
properties.put(JdbcConfig.PASSWORD.getKey(), "test");
- Assertions.assertThrows(
- GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ // createDataSource re-wraps the validation failure, so assert on the
cause to prove the
+ // security check fired (not some unrelated pool/driver error). The
reported name is the
+ // canonical (lower-case) entry from the unsafe-parameter list.
+ Assertions.assertNotNull(gre.getCause());
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'allowloadlocalinfile' detected in JDBC
configuration",
+ gre.getCause().getMessage());
}
@Test
@@ -67,8 +75,13 @@ public class TestDataSourceUrlValidation {
properties.put(JdbcConfig.USERNAME.getKey(), "test");
properties.put(JdbcConfig.PASSWORD.getKey(), "test");
- Assertions.assertThrows(
- GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ Assertions.assertNotNull(gre.getCause());
+ Assertions.assertEquals(
+ "Unsafe PostgreSQL parameter 'socketFactory' detected in JDBC
configuration",
+ gre.getCause().getMessage());
}
@Test
@@ -81,8 +94,37 @@ public class TestDataSourceUrlValidation {
properties.put(JdbcConfig.USERNAME.getKey(), "test");
properties.put(JdbcConfig.PASSWORD.getKey(), "test");
- Assertions.assertThrows(
- GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ Assertions.assertNotNull(gre.getCause());
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'allowloadlocalinfile' detected in JDBC
configuration",
+ gre.getCause().getMessage());
+ }
+
+ @Test
+ public void testRejectMysqlUnsafeParamInConnectionProperties() {
+ // DBCP2 forwards the "connectionProperties" value straight to the JDBC
driver, so an unsafe
+ // MySQL parameter smuggled here must still be rejected. (In the catalog
path a user reaches
+ // this key via the "gravitino.bypass." prefix, which is stripped before
the config map is
+ // handed to the validator.)
+ HashMap<String, String> properties = Maps.newHashMap();
+ properties.put(JdbcConfig.JDBC_DRIVER.getKey(),
"com.mysql.cj.jdbc.Driver");
+ properties.put(JdbcConfig.JDBC_URL.getKey(),
"jdbc:mysql://localhost:3306/test");
+ properties.put(JdbcConfig.USERNAME.getKey(), "test");
+ properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+ properties.put("connectionProperties", "autoDeserialize=true");
+
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class, () ->
DataSourceUtils.createDataSource(properties));
+ // createDataSource re-wraps the validation failure, so assert on the
cause to prove the
+ // security check fired (not some unrelated pool/driver error).
+ Assertions.assertNotNull(gre.getCause());
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getCause().getMessage());
}
@Test
diff --git a/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
b/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
index e157109f1b..b451ed7507 100644
--- a/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
+++ b/common/src/main/java/org/apache/gravitino/utils/JdbcUrlUtils.java
@@ -20,10 +20,17 @@
package org.apache.gravitino.utils;
import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.io.StringReader;
import java.net.URLDecoder;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
@@ -35,6 +42,11 @@ import
org.apache.gravitino.exceptions.GravitinoRuntimeException;
*/
public class JdbcUrlUtils {
+ // The DBCP2 connection-pool property whose value is a list of connection
properties forwarded
+ // verbatim to the JDBC driver. Unsafe parameters smuggled here would
otherwise evade a check
+ // that only inspects the URL string and the raw config values.
+ private static final String CONNECTION_PROPERTIES_KEY =
"connectionProperties";
+
// Unsafe parameters for MySQL and MariaDB, other parameters like
// trustCertificateKeyStoreUrl, serverTimezone, characterEncoding,
// useSSL are also risky, please use it with caution.
@@ -73,7 +85,7 @@ public class JdbcUrlUtils {
Preconditions.checkArgument(StringUtils.isNotBlank(driver), "Driver class
name can't be blank");
Preconditions.checkArgument(StringUtils.isNotBlank(url), "JDBC URL can't
be blank");
- String lowerUrl = url.toLowerCase();
+ String lowerUrl = url.toLowerCase(Locale.ROOT);
String decodedUrl = recursiveDecode(lowerUrl);
if (decodedUrl.startsWith("jdbc:mysql")) {
@@ -88,13 +100,20 @@ public class JdbcUrlUtils {
private static void checkUnsafeParameters(
String url, Map<String, String> config, List<String> unsafeParams,
String dbType) {
- String lowerUrl = url.toLowerCase();
+ // Percent-decoding in recursiveDecode can reintroduce upper-case
characters (e.g. "%4a" ->
+ // 'J'), so lower-case again here rather than relying on the pre-decode
lower-casing.
+ String lowerUrl = url.toLowerCase(Locale.ROOT);
+
+ // Parameter names that reach the JDBC driver through the config map: the
config keys
+ // themselves (defense in depth) plus any names embedded in the DBCP2
"connectionProperties"
+ // value, which is forwarded verbatim to the driver.
+ Set<String> configParamNames = collectConfigParameterNames(config);
for (String param : unsafeParams) {
- String lowerParam = param.toLowerCase();
- if (lowerUrl.contains(lowerParam) || containsValueIgnoreCase(config,
param)) {
+ String lowerParam = param.toLowerCase(Locale.ROOT);
+ if (lowerUrl.contains(lowerParam) ||
configParamNames.contains(lowerParam)) {
throw new GravitinoRuntimeException(
- "Unsafe %s parameter '%s' detected in JDBC URL", dbType, param);
+ "Unsafe %s parameter '%s' detected in JDBC configuration", dbType,
param);
}
}
}
@@ -116,12 +135,56 @@ public class JdbcUrlUtils {
return decoded;
}
- private static boolean containsValueIgnoreCase(Map<String, String> map,
String value) {
- for (String keyValue : map.values()) {
- if (keyValue != null && keyValue.equalsIgnoreCase(value)) {
- return true;
+ /**
+ * Collects, in lower case, every parameter name that the JDBC driver could
observe via the
+ * configuration map. This includes each config key and each name embedded
in the DBCP2 {@code
+ * connectionProperties} value.
+ */
+ private static Set<String> collectConfigParameterNames(Map<String, String>
config) {
+ Set<String> names = new HashSet<>();
+ if (config == null) {
+ return names;
+ }
+
+ for (Map.Entry<String, String> entry : config.entrySet()) {
+ String key = entry.getKey();
+ if (key == null) {
+ continue;
+ }
+ names.add(key.toLowerCase(Locale.ROOT));
+
+ // DBCP2's BasicDataSourceFactory forwards every name in the
"connectionProperties" value to
+ // the driver. It parses that value by replacing ';' with '\n' and
calling Properties.load,
+ // so we parse it identically here to avoid a parser-differential bypass
(e.g. names split by
+ // embedded newlines or hidden behind '\' / '\\uXXXX' escapes that
Properties.load resolves).
+ if (CONNECTION_PROPERTIES_KEY.equalsIgnoreCase(key)) {
+ for (String name : parseConnectionPropertyNames(entry.getValue())) {
+ names.add(name.toLowerCase(Locale.ROOT));
+ }
}
}
- return false;
+ return names;
+ }
+
+ /**
+ * Parses the property names out of a DBCP2 {@code connectionProperties}
value using the same
+ * semantics DBCP2 itself uses: replace {@code ';'} with a newline and load
as a {@link
+ * Properties} document.
+ */
+ private static Set<String> parseConnectionPropertyNames(String value) {
+ if (value == null) {
+ return Collections.emptySet();
+ }
+ Properties properties = new Properties();
+ try {
+ properties.load(new StringReader(value.replace(';', '\n')));
+ } catch (IOException | IllegalArgumentException e) {
+ // A malformed value cannot be parsed into names to inspect; reject it
defensively rather
+ // than letting an un-inspectable value reach the driver. (DBCP2 parses
it the same way and
+ // would fail too.) The cause is chained for server-side diagnostics but
kept out of the
+ // message to avoid leaking internals to clients.
+ throw new GravitinoRuntimeException(e, "Unable to parse JDBC
connectionProperties");
+ }
+ return properties.stringPropertyNames();
}
}
diff --git
a/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
b/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
index f13f1f6316..056178ca72 100644
--- a/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
+++ b/common/src/test/java/org/apache/gravitino/utils/TestJdbcUrlUtils.java
@@ -20,9 +20,13 @@
package org.apache.gravitino.utils;
import java.util.Collections;
+import java.util.Locale;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceAccessMode;
+import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.api.parallel.Resources;
public class TestJdbcUrlUtils {
@@ -70,11 +74,14 @@ public class TestJdbcUrlUtils {
"jdbc:mysql://localhost:0000/test?allowloadlocalinfile=test",
Collections.singletonMap("test", "test")));
Assertions.assertEquals(
- "Unsafe MySQL parameter 'allowloadlocalinfile' detected in JDBC URL",
gre.getMessage());
+ "Unsafe MySQL parameter 'allowloadlocalinfile' detected in JDBC
configuration",
+ gre.getMessage());
}
@Test
public void
whenConfigPropertiesMapContainsUnsafeParam_ShouldThrowGravitinoRuntimeException()
{
+ // The unsafe param appears only as the config KEY (value is unrelated),
so this exercises the
+ // key-based detection rather than incidentally matching on the value.
GravitinoRuntimeException gre =
Assertions.assertThrows(
GravitinoRuntimeException.class,
@@ -82,9 +89,10 @@ public class TestJdbcUrlUtils {
JdbcUrlUtils.validateJdbcConfig(
"testDriver",
"jdbc:mysql://localhost:0000/test",
- Collections.singletonMap("maxAllowedPacket",
"maxAllowedPacket")));
+ Collections.singletonMap("maxAllowedPacket", "1024")));
Assertions.assertEquals(
- "Unsafe MySQL parameter 'maxAllowedPacket' detected in JDBC URL",
gre.getMessage());
+ "Unsafe MySQL parameter 'maxAllowedPacket' detected in JDBC
configuration",
+ gre.getMessage());
}
@Test
@@ -98,7 +106,7 @@ public class TestJdbcUrlUtils {
}
@Test
- public void
whenUnsafeParameterGivenForMariaDB_ShouldThrowGravitintoRuntimeException() {
+ public void
whenUnsafeParameterGivenForMariaDB_ShouldThrowGravitinoRuntimeException() {
GravitinoRuntimeException gre =
Assertions.assertThrows(
GravitinoRuntimeException.class,
@@ -108,7 +116,8 @@ public class TestJdbcUrlUtils {
"jdbc:mariaDB://localhost:0000/test?allowloadlocalinfile=test",
Collections.singletonMap("test", "test")));
Assertions.assertEquals(
- "Unsafe MariaDB parameter 'allowloadlocalinfile' detected in JDBC
URL", gre.getMessage());
+ "Unsafe MariaDB parameter 'allowloadlocalinfile' detected in JDBC
configuration",
+ gre.getMessage());
}
@Test
@@ -132,7 +141,8 @@ public class TestJdbcUrlUtils {
"jdbc:postgresql://localhost:0000/test?socketFactory=test",
Collections.singletonMap("test", "test")));
Assertions.assertEquals(
- "Unsafe PostgreSQL parameter 'socketFactory' detected in JDBC URL",
gre.getMessage());
+ "Unsafe PostgreSQL parameter 'socketFactory' detected in JDBC
configuration",
+ gre.getMessage());
}
@Test
@@ -162,4 +172,384 @@ public class TestJdbcUrlUtils {
IllegalArgumentException.class,
() -> JdbcUrlUtils.validateJdbcConfig("testDriver", "", null));
}
+
+ @Test
+ public void whenConfigIsNullAndUrlIsSafe_ShouldNotThrow() {
+ // A recognized DB URL with a null config map must reach the parameter
check without NPE and
+ // pass (guards the null-config short-circuit in
collectConfigParameterNames).
+ Assertions.assertDoesNotThrow(
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver", "jdbc:mysql://localhost:0000/test", null));
+ }
+
+ @Test
+ public void
whenConnectionPropertiesContainsUnsafeParam_ShouldThrowForMySQL() {
+ // DBCP2's BasicDataSourceFactory recognizes a "connectionProperties" key
whose value is a
+ // ';'-delimited list of driver connection properties (e.g.
"k1=v1;k2=v2"). These are passed
+ // straight to the JDBC driver, so an unsafe MySQL parameter smuggled here
bypasses a check
+ // that only inspects the URL string and the raw config values.
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("connectionProperties",
"autoDeserialize=true")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesContainsMultipleUnsafeParams_ShouldThrowForMySQL() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties",
+
"useCompression=true;queryInterceptors=com.example.Evil")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'queryInterceptors' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesContainsUnsafeParam_ShouldThrowForPostgreSQL() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:postgresql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties",
"socketFactory=com.example.Evil")));
+ Assertions.assertEquals(
+ "Unsafe PostgreSQL parameter 'socketFactory' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void whenUnsafeParamGivenAsConfigKey_ShouldThrowForMySQL() {
+ // Defense in depth: an unsafe parameter supplied directly as a config key
(not embedded in the
+ // URL) must also be rejected.
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("autoDeserialize", "true")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void whenSafeConnectionPropertiesGiven_ShouldNotThrow() {
+ Assertions.assertDoesNotThrow(
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties",
"useCompression=true;connectTimeout=1000")));
+ }
+
+ @Test
+ public void
whenConnectionPropertyNameContainsUnsafeSubstring_ShouldNotThrow() {
+ // Adversarial negative control: a safe name that merely CONTAINS an
unsafe param as a
+ // substring must not be rejected — proves whole-name (not substring)
matching on the config
+ // side.
+ Assertions.assertDoesNotThrow(
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("connectionProperties",
"autoDeserializeHelper=true")));
+ }
+
+ @Test
+ public void whenUnsafeParamAppearsOnlyAsConfigValue_ShouldNotThrow() {
+ // The check inspects parameter NAMES (config keys + connectionProperties
names), not values —
+ // only names reach the driver as connection parameters. An unsafe token
appearing solely as a
+ // value under a benign key must not be rejected. Guards against
reintroducing the removed
+ // value-scanning heuristic, which would reject legitimate catalogs.
+ Assertions.assertDoesNotThrow(
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("jdbc-password", "autoDeserialize")));
+ }
+
+ @Test
+ public void whenConnectionPropertyValueEqualsUnsafeName_ShouldNotThrow() {
+ // Same principle inside connectionProperties: 'autoDeserialize' as the
VALUE of a benign
+ // property name is harmless (the driver receives name 'foo'), so it must
not be rejected.
+ Assertions.assertDoesNotThrow(
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("connectionProperties",
"foo=autoDeserialize")));
+ }
+
+ @Test
+ public void
whenConnectionPropertiesSmugglesUnsafeParamViaNewline_ShouldThrowForMySQL() {
+ // DBCP2 parses the connectionProperties value by replacing ';' with '\n'
and calling
+ // Properties.load, so an embedded newline starts a new property. A parser
that split only on
+ // ';' would miss the second name; parsing exactly as DBCP2 does catches
it.
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties",
"foo=bar\nautoDeserialize=true")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesSmugglesUnsafeParamViaUnicodeEscape_ShouldThrowForMySQL()
{
+ // "\\u0061utoDeserialize" is decoded to "autoDeserialize" by
Properties.load. A naive
+ // substring parser would see the literal "\\u0061..." and miss it.
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties",
"\\u0061utoDeserialize=true")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesKeyHasDifferentCase_ShouldThrowForMySQL() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("ConnectionProperties",
"autoDeserialize=true")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesHasWhitespaceAroundPairs_ShouldThrowForMySQL() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties", "useCompression=true;
autoDeserialize = true")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesContainsUnsafeParam_ShouldThrowForMariaDB() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mariadb://localhost:0000/test",
+ Collections.singletonMap("connectionProperties",
"autoDeserialize=true")));
+ Assertions.assertEquals(
+ "Unsafe MariaDB parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void whenUnsafeParamGivenAsConfigKey_ShouldThrowForPostgreSQL() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:postgresql://localhost:0000/test",
+ Collections.singletonMap("socketFactory",
"com.example.Evil")));
+ Assertions.assertEquals(
+ "Unsafe PostgreSQL parameter 'socketFactory' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void whenConnectionPropertiesHasTrailingSeparators_ShouldStillThrow()
{
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("connectionProperties",
"autoDeserialize=true;;")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void
whenConnectionPropertiesValueIsMalformed_ShouldThrowUnableToParse() {
+ // A "\\uZZZZ" escape makes Properties.load throw
IllegalArgumentException; the validator
+ // rejects the un-inspectable value rather than letting it reach the
driver (DBCP2 parses it
+ // the same way and would also fail).
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("connectionProperties",
"\\uZZZZ=true")));
+ Assertions.assertEquals("Unable to parse JDBC connectionProperties",
gre.getMessage());
+ // The parse cause is chained for server-side diagnostics; guard against a
"simplification"
+ // that drops it.
+ Assertions.assertInstanceOf(IllegalArgumentException.class,
gre.getCause());
+ }
+
+ @Test
+ public void whenUnsafeParamGivenAsConfigKey_ShouldThrowForMariaDB() {
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mariadb://localhost:0000/test",
+ Collections.singletonMap("autoDeserialize", "true")));
+ Assertions.assertEquals(
+ "Unsafe MariaDB parameter 'autoDeserialize' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void whenPercentEscapeDecodesToUpperCaseParam_ShouldThrowForMySQL() {
+ // "%41" decodes to uppercase 'A', so after recursiveDecode the URL
contains
+ // "AllowLoadLocalInfile". The check must re-lower-case the decoded URL
(not rely on the
+ // pre-decode lower-casing) to still match the unsafe parameter.
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+
"jdbc:mysql://localhost:0000/test?%41llowLoadLocalInfile=true",
+ Collections.singletonMap("test", "test")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'allowloadlocalinfile' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ public void whenParamIsDoubleUrlEncoded_ShouldThrowForMySQL() {
+ // "%2561" is a double-encoding of 'a': one decode pass yields "%61", a
second yields 'a'. The
+ // check must decode recursively (not a single pass) to collapse nested
encoding before
+ // matching, otherwise the unsafe parameter slips through.
+ GravitinoRuntimeException gre =
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+
"jdbc:mysql://localhost:0000/test?%2561llowLoadLocalInfile=true",
+ Collections.singletonMap("test", "test")));
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'allowloadlocalinfile' detected in JDBC
configuration",
+ gre.getMessage());
+ }
+
+ @Test
+ @ResourceLock(value = Resources.LOCALE, mode = ResourceAccessMode.READ_WRITE)
+ public void whenDefaultLocaleIsTurkish_ShouldStillMatchParamWithI() {
+ // The attacker-supplied form carries a capital 'I'
('statementInterceptors'). Under the
+ // Turkish locale a default-locale toLowerCase() folds 'I' to a dotless
'ı', while Locale.ROOT
+ // folds it to an ASCII dotted 'i' to match the unsafe-param list entry.
Using the capital-'I'
+ // input at each site means a Locale.ROOT -> toLowerCase() regression at
ANY of the four
+ // input-lowercasing sites (URL pre-decode, URL post-decode, config key,
connectionProperties
+ // name) diverges from the ROOT-folded param and is caught here — a
default-locale CI would not
+ // otherwise catch it.
+ Locale previous = Locale.getDefault();
+ try {
+ Locale.setDefault(Locale.forLanguageTag("tr"));
+
+ // URL path — literal capital 'I' is folded at the pre-decode site.
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'statementInterceptors' detected in JDBC
configuration",
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+
"jdbc:mysql://localhost:0000/test?statementInterceptors=com.example.Evil",
+ Collections.singletonMap("test", "test")))
+ .getMessage());
+
+ // URL path — '%49' decodes to 'I' only AFTER recursiveDecode, so this
exercises the
+ // post-decode re-lowercasing site specifically.
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'statementInterceptors' detected in JDBC
configuration",
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+
"jdbc:mysql://localhost:0000/test?statement%49nterceptors=com.example.Evil",
+ Collections.singletonMap("test", "test")))
+ .getMessage());
+
+ // Config-key path.
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'statementInterceptors' detected in JDBC
configuration",
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap("statementInterceptors",
"com.example.Evil")))
+ .getMessage());
+
+ // connectionProperties-name path.
+ Assertions.assertEquals(
+ "Unsafe MySQL parameter 'statementInterceptors' detected in JDBC
configuration",
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () ->
+ JdbcUrlUtils.validateJdbcConfig(
+ "testDriver",
+ "jdbc:mysql://localhost:0000/test",
+ Collections.singletonMap(
+ "connectionProperties",
"statementInterceptors=com.example.Evil")))
+ .getMessage());
+ } finally {
+ Locale.setDefault(previous);
+ }
+ }
}