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(""));
+ }
+
}