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

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


The following commit(s) were added to refs/heads/main by this push:
     new 5855cfa14d [CALCITE-7532] Model usability
5855cfa14d is described below

commit 5855cfa14d8038e2a123ff6ce9722edce0e0cc25
Author: Julian Hyde <[email protected]>
AuthorDate: Sat May 16 16:31:23 2026 -0700

    [CALCITE-7532] Model usability
---
 .../calcite/config/CalciteSystemProperty.java      |  18 ++
 .../org/apache/calcite/model/ClassNameFilter.java  | 185 +++++++++++++++++++++
 .../org/apache/calcite/model/ModelHandler.java     |  43 ++++-
 .../org/apache/calcite/model/ModelHandlerTest.java | 144 ++++++++++++++++
 4 files changed, 384 insertions(+), 6 deletions(-)

diff --git 
a/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java 
b/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
index f38d405e34..b0efb1a05d 100644
--- a/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
+++ b/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
@@ -455,6 +455,24 @@ public final class CalciteSystemProperty<T> {
   public static final CalciteSystemProperty<Integer> 
JOIN_SELECTOR_COMPACT_CODE_THRESHOLD =
       intProperty("calcite.join.selector.compact.code.threshold", 100);
 
+  /**
+   * Comma-separated patterns to add to the built-in denylist of class
+   * names that may not be loaded by reflection from a Calcite model
+   * (user-defined functions, custom schemas/tables, JDBC drivers,
+   * dialect factories, lattice statistic providers).
+   *
+   * <p>Setting this property <em>extends</em> the built-in denylist; the
+   * built-in entries cannot be removed at runtime.
+   *
+   * <p>Pattern syntax: a pattern ending in {@code "."} matches any class
+   * in that package or its sub-packages; otherwise the pattern matches a
+   * class name exactly.
+   *
+   * @see org.apache.calcite.model.ModelHandler
+   */
+  public static final CalciteSystemProperty<String> MODEL_CLASSES_DENIED =
+      stringProperty("calcite.model.classes.denied", "");
+
   private static CalciteSystemProperty<Boolean> booleanProperty(String key,
       boolean defaultValue) {
     // Note that "" -> true (convenient for command-lines flags like '-Dflag')
diff --git a/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java 
b/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java
new file mode 100644
index 0000000000..5a0a948cdc
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java
@@ -0,0 +1,185 @@
+/*
+ * 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.calcite.model;
+
+import org.apache.calcite.config.CalciteSystemProperty;
+
+import com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Predicate;
+
+/**
+ * Filters class names that may be loaded by reflection from a Calcite
+ * model: user-defined functions, custom schemas, custom tables, JDBC
+ * drivers, dialect factories, and lattice statistic providers.
+ *
+ * <p>{@link #standard()} returns the filter applied by
+ * {@link ModelHandler}: the built-in {@link #DEFAULT_DENYLIST} together
+ * with any patterns from
+ * {@link CalciteSystemProperty#MODEL_CLASSES_DENIED} (which
+ * <em>extends</em> the denylist).
+ *
+ * <p>The denylist is a comma-separated pattern string. A pattern ending
+ * in {@code "."} matches any class in that package or its sub-packages;
+ * otherwise the pattern matches a class name exactly. Whitespace around
+ * commas is ignored.
+ *
+ * <p>The denylist is not a sandbox. Any string passed to a
+ * {@code className}, {@code factory}, {@code jdbcDriver},
+ * {@code sqlDialectFactory}, or {@code statisticProvider} field is
+ * classpath-equivalent; only accept models from trusted sources.
+ */
+class ClassNameFilter implements Predicate<String> {
+  /** Built-in denylist: class-name patterns known to enable RCE when
+   * registered as UDFs, schema/table factories, JDBC drivers, dialect
+   * factories, or lattice statistic providers. */
+  static final String DEFAULT_DENYLIST = ""
+      + "javax.naming.,"
+      + "com.sun.jndi.,"
+      + "java.lang.Runtime,"
+      + "java.lang.ProcessBuilder,"
+      + "java.lang.ProcessImpl,"
+      + "java.lang.System,"
+      + "java.lang.Class,"
+      + "java.lang.reflect.,"
+      + "java.lang.invoke.,"
+      + "javax.script.,"
+      + "bsh.,"
+      + "groovy.,"
+      + "org.codehaus.groovy.,"
+      + "org.python.util.PythonInterpreter,"
+      + "org.springframework.expression.,"
+      + "org.apache.commons.collections.functors.,"
+      + "org.apache.commons.collections4.functors.,"
+      + "org.apache.commons.beanutils.,"
+      + "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,"
+      + "sun.misc.Unsafe,"
+      + "jdk.internal.";
+
+  /** Cache shared by all factory calls; filters are immutable and small,
+   * so identical denylist inputs need only be parsed once. */
+  private static final ConcurrentMap<String, ClassNameFilter> CACHE =
+      new ConcurrentHashMap<>();
+
+  /** The standard filter, built once from the built-in denylist plus
+   * the {@link CalciteSystemProperty#MODEL_CLASSES_DENIED} extension.
+   * Initialized via {@link #of} so it shares the same cache. */
+  private static final ClassNameFilter STANDARD =
+      of(
+          append(DEFAULT_DENYLIST,
+          CalciteSystemProperty.MODEL_CLASSES_DENIED.value()));
+
+  private final ImmutableList<String> denylist;
+
+  private ClassNameFilter(String denylist) {
+    this.denylist = parse(denylist);
+  }
+
+  /** Returns the standard filter used by {@link ModelHandler}: the
+   * built-in {@link #DEFAULT_DENYLIST} (extended by
+   * {@link CalciteSystemProperty#MODEL_CLASSES_DENIED}). */
+  static ClassNameFilter standard() {
+    return STANDARD;
+  }
+
+  /** Returns a filter parsed from a comma-separated denylist pattern
+   * string; may be empty. Filters are cached, so repeated calls with
+   * the same argument return the same instance. */
+  static ClassNameFilter of(String denylist) {
+    return CACHE.computeIfAbsent(denylist, ClassNameFilter::new);
+  }
+
+  /** Returns whether {@code classRef} is allowed (not on the denylist).
+   * A null reference is allowed.
+   *
+   * <p>{@code classRef} may be a plain class name or the
+   * {@code "ClassName#STATIC_FIELD"} form accepted by
+   * {@link org.apache.calcite.avatica.AvaticaUtils#instantiatePlugin};
+   * the field portion is stripped before matching. */
+  @Override public boolean test(@Nullable String classRef) {
+    if (classRef == null) {
+      return true;
+    }
+    String className = stripFieldRef(classRef);
+    for (String pattern : denylist) {
+      if (matches(pattern, className)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /** Throws {@link SecurityException} if {@code classRef} is on the
+   * denylist. A null reference is a no-op. */
+  void check(@Nullable String classRef) {
+    if (classRef == null) {
+      return;
+    }
+    String className = stripFieldRef(classRef);
+    for (String pattern : denylist) {
+      if (matches(pattern, className)) {
+        throw new SecurityException("Class '" + className
+            + "' is rejected by the Calcite class-name filter "
+            + "(matches denylist pattern '" + pattern + "'). "
+            + "If this load is unintended, adjust the model; the "
+            + "denylist cannot be loosened at runtime.");
+      }
+    }
+  }
+
+  private static String stripFieldRef(String classRef) {
+    int hash = classRef.indexOf('#');
+    return hash >= 0 ? classRef.substring(0, hash) : classRef;
+  }
+
+  private static boolean matches(String pattern, String className) {
+    if (pattern.endsWith(".")) {
+      return className.startsWith(pattern);
+    }
+    return className.equals(pattern);
+  }
+
+  /** Returns the concatenation of two comma-separated pattern strings,
+   * inserting a comma if needed and tolerating empty inputs. */
+  static String append(String first, String second) {
+    if (first.isEmpty()) {
+      return second;
+    }
+    if (second.isEmpty()) {
+      return first;
+    }
+    return first + "," + second;
+  }
+
+  private static ImmutableList<String> parse(String list) {
+    if (list.isEmpty()) {
+      return ImmutableList.of();
+    }
+    ImmutableList.Builder<String> b = ImmutableList.builder();
+    for (String s : list.split(",")) {
+      String trimmed = s.trim();
+      if (!trimmed.isEmpty()) {
+        b.add(trimmed);
+      }
+    }
+    return b.build();
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/model/ModelHandler.java 
b/core/src/main/java/org/apache/calcite/model/ModelHandler.java
index 5842619d16..46661065cc 100644
--- a/core/src/main/java/org/apache/calcite/model/ModelHandler.java
+++ b/core/src/main/java/org/apache/calcite/model/ModelHandler.java
@@ -82,14 +82,27 @@ public class ModelHandler {
   private final Deque<Pair<? extends @Nullable String, SchemaPlus>> 
schemaStack =
       new ArrayDeque<>();
   private final String modelUri;
+  private final ClassNameFilter classNameFilter;
   Lattice.@Nullable Builder latticeBuilder;
   Lattice.@Nullable TileBuilder tileBuilder;
 
-  @SuppressWarnings("method.invocation.invalid")
+  /** Creates a {@code ModelHandler} that uses the
+   * {@linkplain ClassNameFilter#standard() standard} class-name filter. */
   public ModelHandler(SchemaPlus rootSchema, String uri) throws IOException {
+    this(rootSchema, uri, ClassNameFilter.standard());
+  }
+
+  /** Creates a {@code ModelHandler} that validates every class loaded
+   * by reflection from the model against {@code classNameFilter}. Use
+   * this to apply a stricter (or more permissive) filter than the
+   * standard one. */
+  @SuppressWarnings("method.invocation.invalid")
+  public ModelHandler(SchemaPlus rootSchema, String uri,
+      ClassNameFilter classNameFilter) throws IOException {
     super();
     this.modelUri = uri;
     this.rootSchema = rootSchema;
+    this.classNameFilter = classNameFilter;
     JsonRoot root;
     ObjectMapper mapper;
     if (uri.startsWith("inline:")) {
@@ -128,7 +141,12 @@ public static void create(SchemaPlus schema, String 
functionName,
   }
 
   /** Creates and validates a {@link ScalarFunctionImpl}, and adds it to a
-   * schema. If {@code methodName} is "*", may add more than one function.
+   * schema, using the {@linkplain ClassNameFilter#standard() standard}
+   * class-name filter. Kept for backwards compatibility; prefer the
+   * filter-taking overload of {@code addFunctions}, which lets callers
+   * supply their own filter.
+   *
+   * <p>If {@code methodName} is "*", may add more than one function.
    *
    * @param schema Schema to add to
    * @param functionName Name of function; null to derived from method name
@@ -144,6 +162,16 @@ public static void create(SchemaPlus schema, String 
functionName,
   public static void addFunctions(SchemaPlus schema,
       @Nullable String functionName, List<String> unusedPath,
       String className, @Nullable String methodName, boolean upCase) {
+    addFunctions(ClassNameFilter.standard(), schema, functionName, className,
+        methodName, upCase);
+  }
+
+  /** Creates and validates a {@link ScalarFunctionImpl} and adds it to a
+   * schema, after asking {@code filter} to accept {@code className}. */
+  public static void addFunctions(ClassNameFilter filter, SchemaPlus schema,
+      @Nullable String functionName, String className,
+      @Nullable String methodName, boolean upCase) {
+    filter.check(className);
     final Class<?> clazz;
     try {
       clazz = Class.forName(className);
@@ -275,6 +303,7 @@ private void populateSchema(JsonSchema jsonSchema, 
SchemaPlus schema) {
   public void visit(JsonCustomSchema jsonSchema) {
     try {
       final SchemaPlus parentSchema = currentMutableSchema("sub-schema");
+      classNameFilter.check(jsonSchema.factory);
       final SchemaFactory schemaFactory =
           AvaticaUtils.instantiatePlugin(SchemaFactory.class,
               jsonSchema.factory);
@@ -329,6 +358,7 @@ protected Map<String, Object> operandMap(@Nullable 
JsonSchema jsonSchema,
 
   public void visit(JsonJdbcSchema jsonSchema) {
     final SchemaPlus parentSchema = currentMutableSchema("jdbc schema");
+    classNameFilter.check(jsonSchema.jdbcDriver);
     final DataSource dataSource =
         JdbcSchema.dataSource(jsonSchema.jdbcUrl,
             jsonSchema.jdbcDriver,
@@ -340,6 +370,7 @@ public void visit(JsonJdbcSchema jsonSchema) {
           JdbcSchema.create(parentSchema, jsonSchema.name, dataSource,
               jsonSchema.jdbcCatalog, jsonSchema.jdbcSchema);
     } else {
+      classNameFilter.check(jsonSchema.sqlDialectFactory);
       SqlDialectFactory factory =
           AvaticaUtils.instantiatePlugin(SqlDialectFactory.class,
               jsonSchema.sqlDialectFactory);
@@ -401,6 +432,7 @@ public void visit(JsonLattice jsonLattice) {
         latticeBuilder.rowCountEstimate(jsonLattice.rowCountEstimate);
       }
       if (jsonLattice.statisticProvider != null) {
+        classNameFilter.check(jsonLattice.statisticProvider);
         latticeBuilder.statisticProvider(jsonLattice.statisticProvider);
       }
       populateLattice(jsonLattice, latticeBuilder);
@@ -421,6 +453,7 @@ private void populateLattice(JsonLattice jsonLattice,
   public void visit(JsonCustomTable jsonTable) {
     try {
       final SchemaPlus schema = currentMutableSchema("table");
+      classNameFilter.check(jsonTable.factory);
       final TableFactory tableFactory =
           AvaticaUtils.instantiatePlugin(TableFactory.class,
               jsonTable.factory);
@@ -518,10 +551,8 @@ public void visit(JsonFunction jsonFunction) {
     // "name" is not required - a class can have several functions
     try {
       final SchemaPlus schema = currentMutableSchema("function");
-      final List<String> path =
-          Util.first(jsonFunction.path, currentSchemaPath());
-      addFunctions(schema, jsonFunction.name, path, jsonFunction.className,
-          jsonFunction.methodName, false);
+      addFunctions(classNameFilter, schema, jsonFunction.name,
+          jsonFunction.className, jsonFunction.methodName, false);
     } catch (Exception e) {
       throw new RuntimeException("Error instantiating " + jsonFunction, e);
     }
diff --git a/core/src/test/java/org/apache/calcite/model/ModelHandlerTest.java 
b/core/src/test/java/org/apache/calcite/model/ModelHandlerTest.java
index 48a546b5db..4cebda1454 100644
--- a/core/src/test/java/org/apache/calcite/model/ModelHandlerTest.java
+++ b/core/src/test/java/org/apache/calcite/model/ModelHandlerTest.java
@@ -21,15 +21,25 @@
 import org.apache.calcite.schema.lookup.LikePattern;
 import org.apache.calcite.util.Sources;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.util.Properties;
 import java.util.Set;
+import java.util.function.Predicate;
 
+import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.CoreMatchers.sameInstance;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import static java.util.Objects.requireNonNull;
 
@@ -56,4 +66,138 @@ public class ModelHandlerTest {
     assertThat(h.defaultSchemaName(), is("SCOTT"));
   }
 
+  @Test void testDenyUdfClass() {
+    SchemaPlus root = CalciteSchema.createRootSchema(false, false).plus();
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            ModelHandler.addFunctions(root, "lookup", ImmutableList.of(),
+                "javax.naming.InitialContext", "doLookup", false));
+    assertThat(e.getMessage(), containsString("javax.naming."));
+    assertThat(e.getMessage(), containsString("denylist"));
+  }
+
+  @Test void testCustomFilterPassedToConstructor() {
+    // A ModelHandler built with a stricter filter must reject classes
+    // its filter denies, even ones the standard filter would allow.
+    SchemaPlus root = CalciteSchema.createRootSchema(false, false).plus();
+    // java.lang.String is not in the standard denylist; the custom
+    // filter denies the whole java.lang. package.
+    ClassNameFilter strict = ClassNameFilter.of("java.lang.");
+    String model = "inline:{"
+        + "  version: '1.0',"
+        + "  defaultSchema: 'X',"
+        + "  schemas: [ {"
+        + "    name: 'X',"
+        + "    functions: [ {"
+        + "      name: 'F',"
+        + "      className: 'java.lang.String'"
+        + "    } ]"
+        + "  } ]"
+        + "}";
+    Throwable e =
+        assertThrows(RuntimeException.class, () ->
+            new ModelHandler(root, model, strict));
+    while (e != null && !(e instanceof SecurityException)) {
+      e = e.getCause();
+    }
+    assertThat("expected SecurityException in chain", e, notNullValue());
+    assertThat(e.getMessage(), containsString("java.lang."));
+  }
+
+  @Test void testAddFunctionsWithExplicitFilterDeniesClass() {
+    // The filter-taking overload of addFunctions must reject any class
+    // its filter denies.
+    SchemaPlus root = CalciteSchema.createRootSchema(false, false).plus();
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            ModelHandler.addFunctions(ClassNameFilter.standard(), root,
+                "lookup", "javax.naming.InitialContext", "doLookup", false));
+    assertThat(e.getMessage(), containsString("javax.naming."));
+  }
+
+  @Test void testDenyFactory() {
+    String model = "inline:{"
+        + "  version: '1.0',"
+        + "  defaultSchema: 'X',"
+        + "  schemas: [ {"
+        + "    name: 'X',"
+        + "    type: 'custom',"
+        + "    factory: 'javax.naming.InitialContext'"
+        + "  } ]"
+        + "}";
+    Properties info = new Properties();
+    info.setProperty("model", model);
+    Exception e =
+        assertThrows(Exception.class, () -> {
+          try (Connection ignored =
+                   DriverManager.getConnection("jdbc:calcite:", info)) {
+            // unreachable
+          }
+        });
+    Throwable cause = e;
+    while (cause != null && !(cause instanceof SecurityException)) {
+      cause = cause.getCause();
+    }
+    assertThat("expected a SecurityException in the chain",
+        cause != null, is(true));
+    assertThat(requireNonNull(cause, "cause").getMessage(),
+        containsString("javax.naming."));
+  }
+
+  @Test void testStaticFieldRefIsCheckedAgainstClass() {
+    // Avatica accepts "ClassName#FIELD" for plugin references; the filter
+    // must reject the class portion regardless of which field is named.
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            ClassNameFilter.standard().check(
+                "java.lang.Runtime#anything"));
+    assertThat(e.getMessage(), containsString("java.lang.Runtime"));
+  }
+
+  @Test void testLegitFactoryClassIsAllowed() {
+    // Sanity: a class outside the denylist passes (no exception).
+    ClassNameFilter.standard().check(
+        "org.apache.calcite.adapter.jdbc.JdbcSchema$Factory");
+    ClassNameFilter.standard().check(
+        "org.apache.calcite.schema.impl.AbstractSchema$Factory");
+    ClassNameFilter.standard().check(
+        "org.apache.calcite.adapter.jdbc.JdbcSchema$Factory#INSTANCE");
+  }
+
+  @Test void testPredicateContract() {
+    // ClassNameFilter implements Predicate<String>: true means "allowed".
+    Predicate<String> filter = ClassNameFilter.standard();
+    assertThat(filter.test(null), is(true));
+    assertThat(filter.test("javax.naming.InitialContext"), is(false));
+    assertThat(filter.test("java.lang.Runtime#getRuntime"), is(false));
+    assertThat(
+        filter.test(
+        "org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"), is(true));
+  }
+
+  @Test void testFactoryMethodsCacheInstances() {
+    // standard() returns a single cached instance.
+    assertThat(ClassNameFilter.standard(),
+        sameInstance(ClassNameFilter.standard()));
+    // of() returns the same instance for equal inputs.
+    ClassNameFilter a = ClassNameFilter.of("com.evil.");
+    ClassNameFilter b = ClassNameFilter.of("com.evil.");
+    assertThat(a, sameInstance(b));
+    // Different inputs produce different instances.
+    ClassNameFilter c = ClassNameFilter.of("com.evil.,com.example.");
+    assertThat(a, not(sameInstance(c)));
+    // The cached filter behaves as configured.
+    assertThat(a.test("com.evil.Payload"), is(false));
+    assertThat(a.test("javax.naming.InitialContext"), is(true));
+  }
+
+  @Test void testAppendCombinesPatternStrings() {
+    // The denylist extension wired into ClassNameFilter.standard() works
+    // by string concatenation through ClassNameFilter.append.
+    assertThat(ClassNameFilter.append("a.,b.", ""), is("a.,b."));
+    assertThat(ClassNameFilter.append("", "c.,d."), is("c.,d."));
+    assertThat(ClassNameFilter.append("a.", "b."), is("a.,b."));
+    assertThat(ClassNameFilter.append("", ""), is(""));
+  }
+
 }

Reply via email to