You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@impala.apache.org by mi...@apache.org on 2026/09/09 20:34:34 UTC

(impala) 09/10: IMPALA-15218: Add trusted_jar_paths for data sources

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

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/impala.git

commit d7b0619f7969a6bcba89fa1a1167d704c80484a9
Author: Michael Smith <mi...@cloudera.com>
AuthorDate: Wed Jun 17 14:02:26 2026 -0700

    IMPALA-15218: Add trusted_jar_paths for data sources
    
    Adds allowlist `trusted_jar_paths` to identify a safe location for
    loading jars for DATA SOURCES and JDBC tables.
    
    Loading arbitrary jars allows code execution, and CREATE TABLE
    permissions need to be granted to people separate from the ability to
    load any jar. Table properties are difficult to authorize correctly, so
    an allowlist is added to put limits on what jars can be loaded into
    memory.
    
    To restore prior behavior, specify `trusted_jar_paths` on impalad
    startup flags as a comma-separated list of location prefixes for known
    JDBC driver jars, such as
    `/test-warehouse/data-sources/,hdfs://test-warehouse/data-sources/`.
    
    Also fixes an SQL injection issue and avoids printing dbcp.password in
    WARNING logs.
    
    Assisted-by: Claude Sonnet 4.6 (VSCode)
    Change-Id: I0bf53fb5eff545d5584ca120e362fd01a2086be1
    Reviewed-on: http://gerrit.cloudera.org:8080/24804
    Reviewed-by: Michael Smith <mi...@cloudera.com>
    Tested-by: Michael Smith <mi...@cloudera.com>
---
 be/src/service/frontend.cc                         |   7 ++
 be/src/util/backend-gflag-util.cc                  |   2 +
 bin/start-impala-cluster.py                        |  18 ++++
 common/thrift/BackendGflags.thrift                 |   2 +
 .../jdbc/conf/JdbcStorageConfigManager.java        |   4 +-
 .../jdbc/dao/DataSourceObjectCache.java            |  26 +++--
 .../jdbc/util/QueryConditionUtil.java              |   7 +-
 .../apache/impala/planner/DataSourceScanNode.java  |  19 ++--
 .../org/apache/impala/service/BackendConfig.java   |  22 +++-
 .../extdatasource/jdbc/JdbcDataSourceTest.java     |  11 ++
 .../org/apache/impala/planner/PlannerTest.java     |  13 +++
 .../apache/impala/service/BackendConfigTest.java   | 114 +++++++++++++++++++++
 tests/custom_cluster/test_ext_data_sources.py      |  61 +++++++++++
 13 files changed, 287 insertions(+), 19 deletions(-)

diff --git a/be/src/service/frontend.cc b/be/src/service/frontend.cc
index a197b21c8..6da76b17a 100644
--- a/be/src/service/frontend.cc
+++ b/be/src/service/frontend.cc
@@ -112,6 +112,13 @@ DEFINE_int32(dbcp_max_wait_millis_for_conn, -1,
 DEFINE_int32(dbcp_data_source_idle_timeout_s, 300,
     "Timeout value in seconds for idle DBCP DataSource objects in cache. It only takes "
     "effect when query option 'clean_dbcp_ds_cache' is set as false.");
+DEFINE_string(trusted_jar_paths, "",
+    "Comma-separated list of URI/path prefixes that are considered safe locations from "
+    "which Impala may download and load jar files. When a 'driver.url' table property "
+    "for JDBC data source tables, or the LOCATION for external data source tables, "
+    "starts with one of these prefixes the jar is downloaded and loaded. An empty value "
+    "(the default) disables jar loading entirely. Example: "
+    "'hdfs://namenode/trusted/jars/,s3://my-bucket/allowed-jars/'");
 
 Frontend::Frontend() {
   JniMethodDescriptor methods[] = {
diff --git a/be/src/util/backend-gflag-util.cc b/be/src/util/backend-gflag-util.cc
index 40eb19911..4cb4177d4 100644
--- a/be/src/util/backend-gflag-util.cc
+++ b/be/src/util/backend-gflag-util.cc
@@ -155,6 +155,7 @@ DECLARE_int32(hbo_max_runs_per_key);
 DECLARE_int64(hbo_in_memory_backend_cache_size_bytes);
 DECLARE_int32(unregistration_thread_pool_size);
 DECLARE_string(cte_suggester_class);
+DECLARE_string(trusted_jar_paths);
 
 // HS2 SAML2.0 configuration
 // Defined here because TAG_FLAG caused issues in global-flags.cc
@@ -647,6 +648,7 @@ Status PopulateThriftBackendGflags(TBackendGflags& cfg) {
   cfg.__set_avro_schema_url_remote_http_enabled(
       FLAGS_avro_schema_url_remote_http_enabled);
   cfg.__set_avro_schema_url_http_allowed_hosts(FLAGS_avro_schema_url_http_allowed_hosts);
+  cfg.__set_trusted_jar_paths(FLAGS_trusted_jar_paths);
   return Status::OK();
 }
 
diff --git a/bin/start-impala-cluster.py b/bin/start-impala-cluster.py
index 59c9999bd..87f82747e 100755
--- a/bin/start-impala-cluster.py
+++ b/bin/start-impala-cluster.py
@@ -50,8 +50,15 @@ KUDU_MASTER_HOSTS = os.getenv("KUDU_MASTER_HOSTS", "127.0.0.1")
 DEFAULT_IMPALA_MAX_LOG_FILES = os.environ.get("IMPALA_MAX_LOG_FILES", 10)
 INTERNAL_LISTEN_HOST = os.getenv("INTERNAL_LISTEN_HOST", "localhost")
 TARGET_FILESYSTEM = os.getenv("TARGET_FILESYSTEM") or "hdfs"
+FILESYSTEM_PREFIX = os.getenv("FILESYSTEM_PREFIX") or ""
+DEFAULT_FS = os.getenv("DEFAULT_FS") or ""
 HOST_TZ = os.getenv("TZ", None)
 
+# Default list of path prefixes from which data source jars may be loaded. Points
+# at the standard test-warehouse location so that existing tests work out of the box.
+DEFAULT_TRUSTED_JAR_PATHS = (FILESYSTEM_PREFIX + "/test-warehouse/data-sources/,"
+                             + DEFAULT_FS + "/test-warehouse/data-sources/")
+
 BOOLEAN_STRINGS = ["true", "false"]
 
 # Options
@@ -222,6 +229,13 @@ parser.add_option("--tcmalloc_aggressive_decommit",
                        "Impalads. This has no effect when using a different malloc "
                        "implementation. This does not change the setting for non-Impalad "
                        "daemons")
+parser.add_option("--trusted_jar_paths", dest="trusted_jar_paths",
+                  default=DEFAULT_TRUSTED_JAR_PATHS,
+                  help="Comma-separated list of path prefixes from which data source "
+                       "jars may be loaded. Sets --trusted_jar_paths on each impalad and "
+                       "catalogd. Defaults to the test-warehouse data-sources directory "
+                       "under the active FILESYSTEM_PREFIX. Pass an empty string to "
+                       "disable jar loading entirely.")
 
 # For testing: list of comma-separated delays, in milliseconds, that delay impalad catalog
 # replica initialization. The ith delay is applied to the ith impalad.
@@ -618,6 +632,10 @@ def build_impalad_arg_lists(cluster_size, num_coordinators, use_exclusive_coordi
           timeout=DISCONNECTED_SESSION_TIMEOUT,
           args=args)
 
+    if "trusted_jar_paths" not in args and options.trusted_jar_paths:
+      args = "-trusted_jar_paths={trusted_jar_paths} {args}".format(
+          trusted_jar_paths=options.trusted_jar_paths, args=args)
+
     if i - start_idx >= num_coordinators:
       args = "-is_coordinator=false {args}".format(args=args)
     elif use_exclusive_coordinators:
diff --git a/common/thrift/BackendGflags.thrift b/common/thrift/BackendGflags.thrift
index 10fa3a52c..63975f8d5 100644
--- a/common/thrift/BackendGflags.thrift
+++ b/common/thrift/BackendGflags.thrift
@@ -382,4 +382,6 @@ struct TBackendGflags {
   176: required bool avro_schema_url_remote_http_enabled
 
   177: required string avro_schema_url_http_allowed_hosts
+
+  178: required string trusted_jar_paths
 }
diff --git a/fe/src/main/java/org/apache/impala/extdatasource/jdbc/conf/JdbcStorageConfigManager.java b/fe/src/main/java/org/apache/impala/extdatasource/jdbc/conf/JdbcStorageConfigManager.java
index d83814604..3c75cf0c4 100644
--- a/fe/src/main/java/org/apache/impala/extdatasource/jdbc/conf/JdbcStorageConfigManager.java
+++ b/fe/src/main/java/org/apache/impala/extdatasource/jdbc/conf/JdbcStorageConfigManager.java
@@ -53,7 +53,9 @@ public class JdbcStorageConfigManager {
     String keystore = conf.get(JdbcStorageConfig.DBCP_PASSWORD_KEYSTORE.
         getPropertyName());
     if (countNonNull(passwd, keystore) > 1) {
-      LOGGER.warn("Only one of " + passwd + ", " + keystore + " can be set");
+      LOGGER.warn("Only one of {} and {} can be set",
+          JdbcStorageConfig.DBCP_PASSWORD.getPropertyName(),
+          JdbcStorageConfig.DBCP_PASSWORD_KEYSTORE.getPropertyName());
     }
     if (passwd == null && keystore != null) {
       String key = conf.get(JdbcStorageConfig.DBCP_PASSWORD_KEY.getPropertyName());
diff --git a/fe/src/main/java/org/apache/impala/extdatasource/jdbc/dao/DataSourceObjectCache.java b/fe/src/main/java/org/apache/impala/extdatasource/jdbc/dao/DataSourceObjectCache.java
index c64ee9e34..eec1c798b 100644
--- a/fe/src/main/java/org/apache/impala/extdatasource/jdbc/dao/DataSourceObjectCache.java
+++ b/fe/src/main/java/org/apache/impala/extdatasource/jdbc/dao/DataSourceObjectCache.java
@@ -109,15 +109,21 @@ public class DataSourceObjectCache {
         BasicDataSource dbcpDs = BasicDataSourceFactory.createDataSource(props);
         String driverLocalPath = null;
         if (!Strings.isNullOrEmpty(driverUrl)) {
-          // Copy jdbc driver to local file system.
-          driverLocalPath = FileSystemUtil.copyFileFromUriToLocal(driverUrl);
-          // Create class loader for jdbc driver and set it for the
-          // BasicDataSource object so that the driver class could be loaded
-          // from jar file without searching classpath.
-          URL driverJarUrl = new File(driverLocalPath).toURI().toURL();
-          URLClassLoader driverLoader = URLClassLoader.newInstance(
-              new URL[] { driverJarUrl }, getClass().getClassLoader());
-          dbcpDs.setDriverClassLoader(driverLoader);
+          if (BackendConfig.INSTANCE.isJarPathAllowed(driverUrl)) {
+            // Copy jdbc driver to local file system.
+            driverLocalPath = FileSystemUtil.copyFileFromUriToLocal(driverUrl);
+            // Create class loader for jdbc driver and set it for the
+            // BasicDataSource object so that the driver class could be loaded
+            // from jar file without searching classpath.
+            URL driverJarUrl = new File(driverLocalPath).toURI().toURL();
+            URLClassLoader driverLoader = URLClassLoader.newInstance(
+                new URL[] { driverJarUrl }, getClass().getClassLoader());
+            dbcpDs.setDriverClassLoader(driverLoader);
+          } else {
+            throw new JdbcDatabaseAccessException(String.format("Loading jar '%s' was "
+                + "prevented because its path is not permitted by '--trusted_jar_paths'.",
+                driverUrl));
+          }
         }
         // Cache the datasource (no need to store driver path since it's in classpath)
         entry = new Entry(dbcpDs, driverLocalPath);
@@ -132,6 +138,8 @@ public class DataSourceObjectCache {
       } catch (SQLException e) {
           throw new JdbcDatabaseAccessException(String.format(
               "Unable to fetch jdbc driver jar from location '%s'. ", driverUrl), e);
+      } catch (JdbcDatabaseAccessException e) {
+          throw e;
       } catch (Exception e) {
           // createDataSource() in commons-dbcp 2.9 throws Exception.
           throw new JdbcDatabaseAccessException(
diff --git a/fe/src/main/java/org/apache/impala/extdatasource/jdbc/util/QueryConditionUtil.java b/fe/src/main/java/org/apache/impala/extdatasource/jdbc/util/QueryConditionUtil.java
index 834648122..473599ec5 100644
--- a/fe/src/main/java/org/apache/impala/extdatasource/jdbc/util/QueryConditionUtil.java
+++ b/fe/src/main/java/org/apache/impala/extdatasource/jdbc/util/QueryConditionUtil.java
@@ -88,9 +88,14 @@ public class QueryConditionUtil {
       // DECIMAL and TIMESTAMP types of predicates are represented as string.
       if (node.getScalar_type().getType() == TPrimitiveType.DECIMAL) {
         // Check column data type and don't add quotes for decimal string.
+        // Decimal strings are numeric; no quoting or escaping is needed.
         sb.append(String.format("%s", value.string_val));
       } else {
-        sb.append(String.format("'%s'", value.string_val));
+        // Escape any embedded single-quote characters using the standard ANSI SQL
+        // convention (replace ' with '') before wrapping in single quotes. Without
+        // this, a string literal such as "O'Brien" would generate 'O'Brien' in the
+        // downstream SQL, breaking out of the string context (CWE-89).
+        sb.append("'").append(value.string_val.replace("'", "''")).append("'");
       }
     } else if (value.isSetDate_val()) {
       sb.append(String.format("'%s'", dbAccessor_.getDateString(value.date_val)));
diff --git a/fe/src/main/java/org/apache/impala/planner/DataSourceScanNode.java b/fe/src/main/java/org/apache/impala/planner/DataSourceScanNode.java
index 77ca35f25..7f05b7e78 100644
--- a/fe/src/main/java/org/apache/impala/planner/DataSourceScanNode.java
+++ b/fe/src/main/java/org/apache/impala/planner/DataSourceScanNode.java
@@ -202,14 +202,19 @@ public class DataSourceScanNode extends ScanNode {
     // source class in current ClassLoader.
     String localPath = null;
     if (!Strings.isNullOrEmpty(dsLocation)) {
-      LOG.trace("Get the instance from URLClassLoader");
-      try {
-        localPath = FileSystemUtil.copyFileFromUriToLocal(dsLocation);
-      } catch (IOException e) {
-        throw new InternalException(String.format(
-            "Unable to fetch data source jar from location '%s'.", dsLocation));
+      if (BackendConfig.INSTANCE.isJarPathAllowed(dsLocation)) {
+        LOG.trace("Get the instance from URLClassLoader");
+        try {
+          localPath = FileSystemUtil.copyFileFromUriToLocal(dsLocation);
+        } catch (IOException e) {
+          throw new InternalException(String.format(
+              "Unable to fetch data source jar from location '%s'.", dsLocation));
+        }
+        Preconditions.checkNotNull(localPath);
+      } else {
+        throw new InternalException(String.format("Loading jar '%s' was prevented "
+            + "because its path is not permitted by '--trusted_jar_paths'.", dsLocation));
       }
-      Preconditions.checkNotNull(localPath);
     } else {
       LOG.trace("Get the instance of the data source class in current ClassLoader");
     }
diff --git a/fe/src/main/java/org/apache/impala/service/BackendConfig.java b/fe/src/main/java/org/apache/impala/service/BackendConfig.java
index 49d18e57b..d1da00662 100644
--- a/fe/src/main/java/org/apache/impala/service/BackendConfig.java
+++ b/fe/src/main/java/org/apache/impala/service/BackendConfig.java
@@ -20,6 +20,8 @@ package org.apache.impala.service;
 import static org.apache.hadoop.fs.CommonConfigurationKeysPublic
     .HADOOP_SECURITY_AUTH_TO_LOCAL;
 
+import java.util.Scanner;
+
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hadoop.security.authentication.util.KerberosName;
 import org.apache.impala.analysis.SqlScanner;
@@ -43,7 +45,8 @@ public class BackendConfig {
   private long warnCatalogResponseSize_;
   private long warnCatalogResponseDurationMs_;
 
-  private BackendConfig(TBackendGflags cfg) {
+  @VisibleForTesting
+  protected BackendConfig(TBackendGflags cfg) {
     backendCfg_ = cfg;
     warnCatalogResponseSize_ = cfg.warn_catalog_response_size_mb * 1024L * 1024L;
     warnCatalogResponseDurationMs_ = cfg.warn_catalog_response_duration_s * 1000L;
@@ -672,4 +675,21 @@ public class BackendConfig {
   public String getAvroSchemaUrlHttpAllowedHosts() {
     return backendCfg_.avro_schema_url_http_allowed_hosts;
   }
+
+  /**
+   * Returns true if 'path' starts with one of the allowed prefixes configured in
+   * --trusted_jar_paths. An empty allowlist (the default) disables jar
+   * loading from table properties entirely.
+   */
+  public boolean isJarPathAllowed(String path) {
+    String allowlist = backendCfg_.trusted_jar_paths;
+    if (Strings.isNullOrEmpty(allowlist) || Strings.isNullOrEmpty(path)) return false;
+    try (Scanner scanner = new Scanner(allowlist).useDelimiter(",")) {
+      while (scanner.hasNext()) {
+        String prefix = scanner.next().trim();
+        if (!prefix.isEmpty() && path.startsWith(prefix)) return true;
+      }
+    }
+    return false;
+  }
 }
diff --git a/fe/src/test/java/org/apache/impala/extdatasource/jdbc/JdbcDataSourceTest.java b/fe/src/test/java/org/apache/impala/extdatasource/jdbc/JdbcDataSourceTest.java
index 00a65d674..9b84569e1 100644
--- a/fe/src/test/java/org/apache/impala/extdatasource/jdbc/JdbcDataSourceTest.java
+++ b/fe/src/test/java/org/apache/impala/extdatasource/jdbc/JdbcDataSourceTest.java
@@ -65,6 +65,17 @@ public class JdbcDataSourceTest {
       BackendConfig.create(new TBackendGflags());
     }
     origFlags = BackendConfig.INSTANCE.getBackendCfg();
+
+    // Initialize trusted_jar_paths to the same default as start-impala-cluster.py:
+    // FILESYSTEM_PREFIX/test-warehouse/data-sources/,
+    // DEFAULT_FS/test-warehouse/data-sources/
+    String filesystemPrefix = System.getenv("FILESYSTEM_PREFIX");
+    if (filesystemPrefix == null) filesystemPrefix = "";
+    String defaultFs = System.getenv("DEFAULT_FS");
+    if (defaultFs == null) defaultFs = "";
+    BackendConfig.INSTANCE.getBackendCfg().setTrusted_jar_paths(
+        filesystemPrefix + "/test-warehouse/data-sources/,"
+        + defaultFs + "/test-warehouse/data-sources/");
   }
 
   @AfterClass
diff --git a/fe/src/test/java/org/apache/impala/planner/PlannerTest.java b/fe/src/test/java/org/apache/impala/planner/PlannerTest.java
index 87e5b1bc5..184f95766 100644
--- a/fe/src/test/java/org/apache/impala/planner/PlannerTest.java
+++ b/fe/src/test/java/org/apache/impala/planner/PlannerTest.java
@@ -38,6 +38,7 @@ import org.apache.impala.catalog.HBaseColumn;
 import org.apache.impala.catalog.Type;
 import org.apache.impala.common.ImpalaException;
 import org.apache.impala.datagenerator.HBaseTestDataRegionAssignment;
+import org.apache.impala.service.BackendConfig;
 import org.apache.impala.service.Frontend.PlanCtx;
 import org.apache.impala.testutil.TestUtils;
 import org.apache.impala.testutil.TestUtils.IgnoreValueFilter;
@@ -65,6 +66,18 @@ public class PlannerTest extends PlannerTestBase {
   @BeforeClass
   public static void setUp() throws Exception {
     PlannerTestBase.setUp();
+
+    // Initialize trusted_jar_paths to the same default as start-impala-cluster.py:
+    // FILESYSTEM_PREFIX/test-warehouse/data-sources/,
+    // DEFAULT_FS/test-warehouse/data-sources/
+    String filesystemPrefix = System.getenv("FILESYSTEM_PREFIX");
+    if (filesystemPrefix == null) filesystemPrefix = "";
+    String defaultFs = System.getenv("DEFAULT_FS");
+    if (defaultFs == null) defaultFs = "";
+    BackendConfig.INSTANCE.getBackendCfg().setTrusted_jar_paths(
+        filesystemPrefix + "/test-warehouse/data-sources/,"
+        + defaultFs + "/test-warehouse/data-sources/");
+
     // Rebalance the HBase tables. This is necessary because some tests rely on HBase
     // tables being arranged in a deterministic way. See IMPALA-7061 for details.
 
diff --git a/fe/src/test/java/org/apache/impala/service/BackendConfigTest.java b/fe/src/test/java/org/apache/impala/service/BackendConfigTest.java
new file mode 100644
index 000000000..f56355010
--- /dev/null
+++ b/fe/src/test/java/org/apache/impala/service/BackendConfigTest.java
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.impala.service;
+
+import org.apache.impala.thrift.TBackendGflags;
+import org.junit.Test;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class BackendConfigTest {
+  private BackendConfig configWith(String trustedJarPaths) {
+    TBackendGflags gflags = new TBackendGflags();
+    gflags.trusted_jar_paths = trustedJarPaths;
+    return new BackendConfig(gflags);
+  }
+
+  // --- isJarPathAllowed ---
+
+  @Test
+  public void testIsJarPathAllowed_emptyAllowlist_returnsFalse() {
+    BackendConfig cfg = configWith("");
+    assertFalse(cfg.isJarPathAllowed("/trusted/path/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_nullAllowlist_returnsFalse() {
+    BackendConfig cfg = configWith(null);
+    assertFalse(cfg.isJarPathAllowed("/trusted/path/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_nullPath_returnsFalse() {
+    BackendConfig cfg = configWith("/trusted/");
+    assertFalse(cfg.isJarPathAllowed(null));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_emptyPath_returnsFalse() {
+    BackendConfig cfg = configWith("/trusted/");
+    assertFalse(cfg.isJarPathAllowed(""));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_exactPrefixMatch_returnsTrue() {
+    BackendConfig cfg = configWith("/trusted/");
+    assertTrue(cfg.isJarPathAllowed("/trusted/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_pathEqualsPrefix_returnsTrue() {
+    BackendConfig cfg = configWith("/trusted/path");
+    assertTrue(cfg.isJarPathAllowed("/trusted/path"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_noMatchingPrefix_returnsFalse() {
+    BackendConfig cfg = configWith("/trusted/");
+    assertFalse(cfg.isJarPathAllowed("/untrusted/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_multipleAllowlistEntries_firstMatches_returnsTrue() {
+    BackendConfig cfg = configWith("/trusted/,/other/path/");
+    assertTrue(cfg.isJarPathAllowed("/trusted/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_multipleAllowlistEntries_secondMatches_returnsTrue() {
+    BackendConfig cfg = configWith("/trusted/,/other/path/");
+    assertTrue(cfg.isJarPathAllowed("/other/path/bar.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_multipleAllowlistEntries_noneMatch_returnsFalse() {
+    BackendConfig cfg = configWith("/trusted/,/other/path/");
+    assertFalse(cfg.isJarPathAllowed("/somewhere/else/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_allowlistWithWhitespace_trimsAndMatches() {
+    BackendConfig cfg = configWith("  /trusted/  ,  /other/path/  ");
+    assertTrue(cfg.isJarPathAllowed("/trusted/foo.jar"));
+    assertTrue(cfg.isJarPathAllowed("/other/path/bar.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_pathContainsPrefixDoesNotStartWithIt_returnsFalse() {
+    BackendConfig cfg = configWith("/trusted/");
+    assertFalse(cfg.isJarPathAllowed("/not/trusted/foo.jar"));
+  }
+
+  @Test
+  public void testIsJarPathAllowed_allowlistWithBlankEntry_ignoresBlank() {
+    // A comma-only allowlist produces one blank entry, which should not match anything.
+    BackendConfig cfg = configWith(",");
+    assertFalse(cfg.isJarPathAllowed("/trusted/foo.jar"));
+  }
+}
diff --git a/tests/custom_cluster/test_ext_data_sources.py b/tests/custom_cluster/test_ext_data_sources.py
index 77d52604d..d2cd02cf6 100644
--- a/tests/custom_cluster/test_ext_data_sources.py
+++ b/tests/custom_cluster/test_ext_data_sources.py
@@ -1303,3 +1303,64 @@ class TestJdbcMtDopBenchmark(CustomClusterTestSuite):
     assert len(active) >= mt_dop, (
         "Expected at least %d active instances, got %d.\nRows: %s"
         % (mt_dop, len(active), rows))
+
+
+class TestJarLoadingAllowlist(CustomClusterTestSuite):
+  """Tests that attempting to load jars outside --trusted_jar_paths skips loading the jar
+  and issues a warning."""
+
+  @classmethod
+  def add_test_dimensions(cls):
+    super(TestJarLoadingAllowlist, cls).add_test_dimensions()
+    cls.ImpalaTestMatrix.add_dimension(create_exec_option_dimension())
+
+  def _create_jdbc_table(self, unique_database, driver_class="com.example.NotADriver"):
+    """Create a minimal JDBC table with driver.url."""
+    self.execute_query("""
+      CREATE EXTERNAL TABLE {db}.jar_allowlist_tbl (id INT)
+      STORED BY JDBC
+      TBLPROPERTIES (
+        "database.type"="POSTGRES",
+        "jdbc.url"="jdbc:postgresql://localhost:5432/functional",
+        "jdbc.driver"="{driver_class}",
+        "driver.url"="{pre}/test-warehouse/data-sources/jdbc-drivers/not-a-jdbc.jar",
+        "dbcp.username"="hiveuser",
+        "dbcp.password"="password",
+        "table"="alltypes")
+    """.format(db=unique_database, pre=FILESYSTEM_PREFIX, driver_class=driver_class))
+
+  @pytest.mark.execute_serially
+  @CustomClusterTestSuite.with_args(
+      impalad_args="--trusted_jar_paths=/trusted/path/",
+      cluster_size=1, disable_log_buffering=True)
+  def test_jar_at_untrusted_path_not_loaded(self, unique_database):
+    """When driver.url is outside the trusted-path allowlist the JDBC jar is not
+    fetched. The query fails and a WARNING is emitted to the impalad log."""
+    self._create_jdbc_table(unique_database)
+    ex = self.execute_query_expect_failure(self.client,
+        "SELECT count(*) FROM {0}.jar_allowlist_tbl".format(unique_database))
+    assert "prevented because its path is not permitted" in str(ex)
+
+  @pytest.mark.execute_serially
+  @CustomClusterTestSuite.with_args(
+      impalad_args="--trusted_jar_paths=",
+      cluster_size=1, disable_log_buffering=True)
+  def test_empty_allowlist_rejects_all_jars(self, unique_database):
+    """An empty --trusted_jar_paths disables jar loading entirely. The query fails and a
+    WARNING is emitted to the impalad log."""
+    self._create_jdbc_table(unique_database)
+    ex = self.execute_query_expect_failure(self.client,
+        "SELECT count(*) FROM {0}.jar_allowlist_tbl".format(unique_database))
+    assert "prevented because its path is not permitted" in str(ex)
+
+  @pytest.mark.execute_serially
+  @CustomClusterTestSuite.with_args(
+      impalad_args="--trusted_jar_paths=",
+      cluster_size=1, disable_log_buffering=True)
+  def test_empty_allowlist_uses_classpath(self, unique_database):
+    """Jar is not loaded, but the driver class is found on the classpath.
+    The query still fails."""
+    self._create_jdbc_table(unique_database, driver_class="org.postgresql.Driver")
+    ex = self.execute_query_expect_failure(self.client,
+        "SELECT count(*) FROM {0}.jar_allowlist_tbl".format(unique_database))
+    assert "prevented because its path is not permitted" in str(ex)