This is an automated email from the ASF dual-hosted git repository.

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new fc88ddc8d4 [#12035] improvement(catalog-jdbc): block DBCP2 
class-loading properties in datasource config (#12036)
fc88ddc8d4 is described below

commit fc88ddc8d4e8c35bb0d06cebd125a4559e771084
Author: YangJie <[email protected]>
AuthorDate: Mon Jul 20 16:55:46 2026 +0800

    [#12035] improvement(catalog-jdbc): block DBCP2 class-loading properties in 
datasource config (#12036)
    
    ### What changes were proposed in this pull request?
    
    Add `rejectUnsafePoolProperties` in `DataSourceUtils`, rejecting the
    DBCP2 pool properties `connectionFactoryClassName`,
    `evictionPolicyClassName`, `driverClassName`, and `initialSize`
    (case-insensitive whole-key match) before the config map is handed to
    `BasicDataSourceFactory`.
    
    ### Why are the changes needed?
    
    DBCP2's factory loads and instantiates the class named by the first
    three via reflection (`Class.forName` + `newInstance`), and
    `initialSize` > 0 forces an eager connection — triggering that driver
    load — during `createDataSource`, before Gravitino's explicit
    `setDriverClassName` override. Reachable via
    `gravitino.bypass.<key>=...`, this lets a catalog creator run arbitrary
    classes on the server (RCE).
    
    Fix: #12035
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. These properties are never used by a legitimate Gravitino JDBC
    catalog (the driver comes from `jdbc-driver`; pool sizing from
    `jdbc.pool.*`). Supplying one now fails fast with a clear message.
    
    ### How was this patch tested?
    
    New unit tests in `TestDataSourceUrlValidation` covering each rejected
    property (including case-insensitive variants) and a whole-key negative
    control proving a similarly-named legitimate property still builds.
    `./gradlew :catalogs:catalog-jdbc-common:test -PskipITs` and spotless
    pass.
---
 .../catalog/jdbc/utils/DataSourceUtils.java        |  56 +++++
 .../jdbc/utils/TestDataSourceUrlValidation.java    | 257 ++++++++++++++++++++-
 2 files changed, 307 insertions(+), 6 deletions(-)

diff --git 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
index a2b8e12b80..e06f376c7b 100644
--- 
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
+++ 
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/utils/DataSourceUtils.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.jdbc.utils;
 
 import java.sql.SQLException;
 import java.time.Duration;
+import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import javax.sql.DataSource;
@@ -39,6 +40,32 @@ public class DataSourceUtils {
   /** SQL statements for database connection pool testing. */
   private static final String POOL_TEST_QUERY = "SELECT 1";
 
+  // DBCP2 connection-pool properties that must never come from catalog 
configuration. The whole
+  // config map is handed to BasicDataSourceFactory, so allowing these would 
either run arbitrary
+  // code or let a raw property override the validated canonical connection 
fields:
+  //   - connectionFactoryClassName / evictionPolicyClassName / 
driverClassName: their values are
+  //     class names the factory loads and instantiates via reflection 
(Class.forName + newInstance)
+  //     when the pool creates connections (remote code execution). The 
legitimate driver is set
+  //     separately from the "jdbc-driver" property via an explicit setter, so 
the raw
+  //     "driverClassName" key is never needed.
+  //   - url / username / password: the connection identity is validated 
(JdbcUrlUtils, the H2
+  //     guard) and applied from the canonical 
"jdbc-url"/"jdbc-user"/"jdbc-password" properties via
+  //     explicit setters. A raw DBCP key here would reach the factory 
unvalidated and, once the
+  //     pool initializes, take effect before those setters run — e.g. a raw 
"url" could smuggle an
+  //     H2 or otherwise-denied URL past the guards.
+  //   - initialSize: defense in depth. A value > 0 makes the factory eagerly 
open a connection
+  //     during createDataSource; blocking it keeps pool creation lazy so our 
explicit url/driver
+  //     setters, not factory-time eager init, control how connections are 
created.
+  private static final List<String> UNSAFE_POOL_PROPERTIES =
+      List.of(
+          "connectionFactoryClassName",
+          "evictionPolicyClassName",
+          "driverClassName",
+          "url",
+          "username",
+          "password",
+          "initialSize");
+
   public static DataSource createDataSource(Map<String, String> properties) {
     return createDataSource(new JdbcConfig(properties));
   }
@@ -56,6 +83,10 @@ public class DataSourceUtils {
     if (jdbcConfig.getJdbcDriver().toLowerCase().startsWith("org.h2.")) {
       throw new GravitinoRuntimeException("H2 JDBC driver is not allowed in 
catalog configuration");
     }
+    // Reject DBCP2 pool properties that load arbitrary classes via reflection 
before handing the
+    // config to the factory. Kept outside the try below so the specific 
reason surfaces directly
+    // instead of being wrapped as "Error creating datasource".
+    rejectUnsafePoolProperties(jdbcConfig.getAllConfig());
     try {
       return createDBCPDataSource(jdbcConfig);
     } catch (Exception exception) {
@@ -92,6 +123,31 @@ public class DataSourceUtils {
     return properties;
   }
 
+  /**
+   * Rejects DBCP2 connection-pool properties that would let catalog 
configuration run arbitrary
+   * classes on the server ({@code connectionFactoryClassName}, {@code 
evictionPolicyClassName},
+   * {@code driverClassName}) or override the validated canonical connection 
identity ({@code url},
+   * {@code username}, {@code password}), plus {@code initialSize} (blocked as 
defense in depth to
+   * keep pool creation lazy).
+   *
+   * @param config the JDBC configuration properties forwarded to the DBCP2 
factory
+   * @throws GravitinoRuntimeException if an unsafe connection-pool property 
is present
+   */
+  private static void rejectUnsafePoolProperties(Map<String, String> config) {
+    if (config == null) {
+      return;
+    }
+    for (String key : config.keySet()) {
+      for (String unsafe : UNSAFE_POOL_PROPERTIES) {
+        if (unsafe.equalsIgnoreCase(key)) {
+          throw new GravitinoRuntimeException(
+              "Unsafe JDBC connection pool property '%s' is not allowed in 
catalog configuration",
+              key);
+        }
+      }
+    }
+  }
+
   private static String recursiveDecode(String url) {
     String prev;
     String decoded = url;
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..41ac39cfe6 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
@@ -44,6 +44,24 @@ public class TestDataSourceUrlValidation {
     ((BasicDataSource) dataSource).close();
   }
 
+  @Test
+  public void testSimilarNamedPropertyIsNotRejected() throws SQLException {
+    // The unsafe-pool-property guard matches the whole key 
(equalsIgnoreCase), so a legitimate key
+    // that merely contains an unsafe name as a substring must still build. 
Guards against a
+    // regression to contains/startsWith matching.
+    HashMap<String, String> properties = Maps.newHashMap();
+    properties.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC");
+    properties.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:");
+    properties.put(JdbcConfig.USERNAME.getKey(), "test");
+    properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+    properties.put("connectionFactoryClassNameHelper", "com.example.Fine");
+
+    DataSource dataSource =
+        Assertions.assertDoesNotThrow(() -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertTrue(dataSource instanceof BasicDataSource);
+    ((BasicDataSource) dataSource).close();
+  }
+
   @Test
   public void testRejectMysqlAllowLoadLocalInfile() {
     HashMap<String, String> properties = Maps.newHashMap();
@@ -53,8 +71,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));
+    // validateJdbcConfig throws inside the try, so the specific reason is the 
wrapped cause.
+    Assertions.assertNotNull(gre.getCause());
+    Assertions.assertTrue(
+        
gre.getCause().getMessage().toLowerCase().contains("allowloadlocalinfile"));
   }
 
   @Test
@@ -67,8 +90,11 @@ 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.assertTrue(gre.getCause().getMessage().contains("socketFactory"));
   }
 
   @Test
@@ -81,8 +107,12 @@ 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.assertTrue(
+        
gre.getCause().getMessage().toLowerCase().contains("allowloadlocalinfile"));
   }
 
   @Test
@@ -147,4 +177,219 @@ public class TestDataSourceUrlValidation {
     Assertions.assertEquals(
         "H2 JDBC driver is not allowed in catalog configuration", 
gre.getMessage());
   }
+
+  @Test
+  public void testRejectConnectionFactoryClassName() {
+    // DBCP2's BasicDataSourceFactory loads "connectionFactoryClassName" via 
Class.forName and
+    // instantiates it at pool init, which is a remote-code-execution vector. 
A user can reach this
+    // key through the "gravitino.bypass." prefix (stripped before the config 
map is handed here),
+    // so it must be rejected.
+    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("connectionFactoryClassName", "com.example.Evil");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'connectionFactoryClassName' is 
not allowed in"
+            + " catalog configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectEvictionPolicyClassName() {
+    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("evictionPolicyClassName", "com.example.Evil");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'evictionPolicyClassName' is not 
allowed in catalog"
+            + " configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectConnectionFactoryClassNameCaseInsensitive() {
+    // The guard matches case-insensitively (broader than DBCP2's own 
case-sensitive key lookup),
+    // so a mis-cased variant is rejected outright rather than silently 
ignored. The error echoes
+    // the user-supplied key casing.
+    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("ConnectionFactoryClassName", "com.example.Evil");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'ConnectionFactoryClassName' is 
not allowed in"
+            + " catalog configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectEvictionPolicyClassNameCaseInsensitive() {
+    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("EVICTIONPOLICYCLASSNAME", "com.example.Evil");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'EVICTIONPOLICYCLASSNAME' is not 
allowed in catalog"
+            + " configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectDriverClassName() {
+    // DBCP2's BasicDataSourceFactory loads the "driverClassName" value via 
Class.forName and
+    // instantiates it (an RCE vector) when the pool creates a connection. The 
legitimate driver is
+    // set separately from the "jdbc-driver" property, so the raw key must be 
rejected.
+    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("driverClassName", "com.example.Evil");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'driverClassName' is not allowed 
in catalog"
+            + " configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectInitialSize() {
+    // initialSize > 0 makes the factory eagerly open a connection during 
createDataSource; reject
+    // it (defense in depth) so pool creation stays lazy and our explicit 
url/driver setters control
+    // how connections are created.
+    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("initialSize", "1");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'initialSize' is not allowed in 
catalog"
+            + " configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectRawUrlProperty() {
+    // A raw DBCP "url" key would reach the factory unvalidated and, once the 
pool initializes, take
+    // effect before the canonical setUrl override — smuggling a denied URL 
(e.g. H2) past the
+    // guards. The canonical URL is supplied via "jdbc-url", so the raw key 
must be rejected.
+    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("url", "jdbc:h2:mem:evil");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'url' is not allowed in catalog 
configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectRawUsernameProperty() {
+    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("username", "root");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'username' is not allowed in 
catalog configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRejectRawPasswordProperty() {
+    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("password", "secret");
+
+    GravitinoRuntimeException gre =
+        Assertions.assertThrows(
+            GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals(
+        "Unsafe JDBC connection pool property 'password' is not allowed in 
catalog configuration",
+        gre.getMessage());
+  }
+
+  @Test
+  public void testRawUrlBypassCannotSmuggleH2ViaEagerInit() {
+    // Regression for the factory-init ordering issue: even with initialSize > 
0 forcing eager pool
+    // init AND a raw "url" pointing at H2, the config must be rejected before 
the factory runs, so
+    // the H2 URL never becomes the live connection.
+    HashMap<String, String> properties = Maps.newHashMap();
+    properties.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC");
+    properties.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:");
+    properties.put(JdbcConfig.USERNAME.getKey(), "test");
+    properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+    properties.put("url", "jdbc:h2:mem:evil");
+    properties.put("initialSize", "1");
+
+    // Rejected on the first unsafe key encountered (url or initialSize); the 
point is that
+    // creation fails before BasicDataSourceFactory initializes the pool with 
the raw url.
+    Assertions.assertThrows(
+        GravitinoRuntimeException.class, () -> 
DataSourceUtils.createDataSource(properties));
+  }
+
+  @Test
+  public void testValidConnectionUsesCanonicalUrl() throws SQLException {
+    // Positive control: with only canonical keys, the live connection uses 
the canonical URL, not
+    // any bootstrap value — proving the ordering fix holds for legitimate 
configs.
+    HashMap<String, String> properties = Maps.newHashMap();
+    properties.put(JdbcConfig.JDBC_DRIVER.getKey(), "org.sqlite.JDBC");
+    properties.put(JdbcConfig.JDBC_URL.getKey(), "jdbc:sqlite::memory:");
+    properties.put(JdbcConfig.USERNAME.getKey(), "test");
+    properties.put(JdbcConfig.PASSWORD.getKey(), "test");
+
+    BasicDataSource dataSource =
+        (BasicDataSource)
+            Assertions.assertDoesNotThrow(() -> 
DataSourceUtils.createDataSource(properties));
+    Assertions.assertEquals("jdbc:sqlite::memory:", dataSource.getUrl());
+    try (java.sql.Connection connection = dataSource.getConnection()) {
+      Assertions.assertEquals("jdbc:sqlite::memory:", 
connection.getMetaData().getURL());
+    } finally {
+      dataSource.close();
+    }
+  }
 }

Reply via email to