This is an automated email from the ASF dual-hosted git repository.
zabetak 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 a6051a6e00 [CALCITE-7713] Add allowlist option in ClassNameFilter
a6051a6e00 is described below
commit a6051a6e00b200a15a4b7f28bebd6a0a2a5b3384
Author: Stamatis Zampetakis <[email protected]>
AuthorDate: Wed Jun 24 19:53:28 2026 +0200
[CALCITE-7713] Add allowlist option in ClassNameFilter
---
.github/workflows/main.yml | 8 ++
build.gradle.kts | 1 +
.../calcite/config/CalciteSystemProperty.java | 19 +++++
.../org/apache/calcite/model/ClassNameFilter.java | 89 +++++++++-------------
.../apache/calcite/model/ClassNameFilterTest.java | 89 ++++++++++++++++++++++
.../org/apache/calcite/model/ModelHandlerTest.java | 25 ++----
site/_docs/history.md | 5 ++
sqlsh | 3 +-
sqlsh.bat | 4 +-
9 files changed, 170 insertions(+), 73 deletions(-)
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 165f2752ab..df77ec38ee 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -71,6 +71,8 @@ jobs:
remote-build-cache-proxy-enabled: false
arguments: --scan --no-parallel --no-daemon build javadoc
- name: 'sqlline and sqllsh'
+ env:
+ JAVA_OPTS: "-Dcalcite.model.classes.allowed=org.apache."
shell: cmd
run: |
call sqlline.bat -e '!quit'
@@ -101,6 +103,8 @@ jobs:
remote-build-cache-proxy-enabled: false
arguments: --scan --no-parallel --no-daemon build
- name: 'sqlline and sqllsh'
+ env:
+ JAVA_OPTS: "-Dcalcite.model.classes.allowed=org.apache."
shell: cmd
run: |
call sqlline.bat -e '!quit'
@@ -131,6 +135,8 @@ jobs:
remote-build-cache-proxy-enabled: false
arguments: --scan --no-parallel --no-daemon build
- name: 'sqlline and sqllsh'
+ env:
+ JAVA_OPTS: "-Dcalcite.model.classes.allowed=org.apache."
shell: cmd
run: |
call sqlline.bat -e '!quit'
@@ -324,6 +330,8 @@ jobs:
remote-build-cache-proxy-enabled: false
arguments: --scan --no-parallel --no-daemon build javadoc
- name: 'sqlline and sqllsh'
+ env:
+ JAVA_OPTS: "-Dcalcite.model.classes.allowed=org.apache."
run: |
./sqlline -e '!quit'
echo
diff --git a/build.gradle.kts b/build.gradle.kts
index 44e00c7fa3..4a45616720 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -915,6 +915,7 @@ fun passProperty(name: String, default: String? = null) {
passProperty("user.timezone", "UTC")
passProperty("calcite.avatica.version",
props.string("calcite.avatica.version"))
passProperty("gradle.rootDir", rootDir.toString())
+ systemProperty("calcite.model.classes.allowed",
"org.,java.lang.")
val props = System.getProperties()
for (e in props.propertyNames() as
`java.util`.Enumeration<String>) {
if (e.startsWith("calcite.") || e.startsWith("avatica.")) {
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 b0efb1a05d..39ba5c2f32 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,25 @@ 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 allowlist of class-name patterns that may be loaded
+ * by reflection from a Calcite model (user-defined functions, custom
+ * schemas/tables, JDBC drivers, dialect factories, lattice statistic
+ * providers).
+ *
+ * <p>By default, the allowlist is empty and class loading is fully disabled.
+ * When non-empty, a class name must match the allowlist in addition to
+ * clearing the denylist.
+ *
+ * <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_ALLOWED =
+ stringProperty("calcite.model.classes.allowed", "");
+
/**
* Comma-separated patterns to add to the built-in denylist of class
* names that may not be loaded by reflection from a Calcite model
diff --git a/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java
b/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java
index 5a0a948cdc..0c37c2c5cb 100644
--- a/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java
+++ b/core/src/main/java/org/apache/calcite/model/ClassNameFilter.java
@@ -20,34 +20,25 @@
import com.google.common.collect.ImmutableList;
+import org.apiguardian.api.API;
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;
+ * <p>The behavior of the filter is determined by the allowlist and the
denylist.
+ * Both lists are comma separated patterns determining a package or class name.
+ * 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> {
+@API(since = "1.43.0", status = API.Status.EXPERIMENTAL)
+public final class ClassNameFilter {
/** 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. */
@@ -75,74 +66,68 @@ class ClassNameFilter implements Predicate<String> {
+ "jdk.internal.";
/** Cache shared by all factory calls; filters are immutable and small,
- * so identical denylist inputs need only be parsed once. */
+ * so identical (denylist, allowlist) 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. */
+ * the {@link CalciteSystemProperty} pair. Initialized via {@link #of}
+ * so it shares the same cache. */
private static final ClassNameFilter STANDARD =
of(
append(DEFAULT_DENYLIST,
- CalciteSystemProperty.MODEL_CLASSES_DENIED.value()));
+ CalciteSystemProperty.MODEL_CLASSES_DENIED.value()),
+ CalciteSystemProperty.MODEL_CLASSES_ALLOWED.value());
private final ImmutableList<String> denylist;
+ private final ImmutableList<String> allowlist;
- private ClassNameFilter(String denylist) {
+ private ClassNameFilter(String denylist, String allowlist) {
this.denylist = parse(denylist);
+ this.allowlist = parse(allowlist);
}
- /** Returns the standard filter used by {@link ModelHandler}: the
+ /** Returns the standard filter used by {@link ModelHandler}. The
* built-in {@link #DEFAULT_DENYLIST} (extended by
- * {@link CalciteSystemProperty#MODEL_CLASSES_DENIED}). */
+ * {@link CalciteSystemProperty#MODEL_CLASSES_DENIED}) plus the
+ * allowlist from
+ * {@link CalciteSystemProperty#MODEL_CLASSES_ALLOWED}. */
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 a filter parsed from comma-separated {@code denylist} and
+ * {@code allowlist} pattern strings; either may be empty. Filters are
+ * cached, so repeated calls with the same arguments return the same
+ * instance. */
+ public static ClassNameFilter of(String denylist, String allowlist) {
+ // NUL is forbidden in JVM class names, so concatenating with NUL is
+ // an injection-proof cache key.
+ String key = denylist + '\0' + allowlist;
+ return CACHE.computeIfAbsent(key,
+ k -> new ClassNameFilter(denylist, allowlist));
}
- /** 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) {
+ /** Throws {@link SecurityException} if {@code classRef} is not allowed
+ * by this filter. A null reference is a no-op. */
+ void check(@Nullable String classRef) {
if (classRef == null) {
- return true;
+ return;
}
String className = stripFieldRef(classRef);
for (String pattern : denylist) {
if (matches(pattern, className)) {
- return false;
+ throw new SecurityException(
+ "Class '" + className + "' rejected by the denylist (pattern '" +
pattern + "').");
}
}
- 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) {
+ for (String pattern : allowlist) {
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.");
+ return;
}
}
+ throw new SecurityException("Class '" + className + "' rejected by the
allowlist.");
}
private static String stripFieldRef(String classRef) {
diff --git
a/core/src/test/java/org/apache/calcite/model/ClassNameFilterTest.java
b/core/src/test/java/org/apache/calcite/model/ClassNameFilterTest.java
new file mode 100644
index 0000000000..be459d9611
--- /dev/null
+++ b/core/src/test/java/org/apache/calcite/model/ClassNameFilterTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Unit tests for {@link ClassNameFilter}.
+ */
+public class ClassNameFilterTest {
+ @Test void testDefaultProdValuesForDenyAllowList() {
+ // This represents the default prod configuration of the project
+ // where the system properties both default to empty and
+ // basically every class is rejected mainly due to the empty allowlist
+ ClassNameFilter cf = ClassNameFilter.of("", "");
+ assertThrows(SecurityException.class, () -> cf.check("java.lang.String"));
+ assertThrows(SecurityException.class,
+ () -> cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ }
+
+ @Test void testAllowListWithSinglePackagePattern() {
+ ClassNameFilter cf = ClassNameFilter.of("", "org.");
+ assertThrows(SecurityException.class, () -> cf.check("java.lang.String"));
+ assertDoesNotThrow(() ->
cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ }
+
+ @Test void testAllowListWithSinglePackageNoPattern() {
+ ClassNameFilter cf = ClassNameFilter.of("", "org");
+ assertThrows(SecurityException.class, () -> cf.check("java.lang.String"));
+ assertThrows(SecurityException.class,
+ () -> cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ }
+
+ @Test void testAllowListWithMultiplePackagePatterns() {
+ ClassNameFilter cf = ClassNameFilter.of("", "org.,java.");
+ assertDoesNotThrow(() -> cf.check("java.lang.String"));
+ assertDoesNotThrow(() ->
cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ assertThrows(SecurityException.class, () ->
cf.check("com.sun.media.sound.Toolkit"));
+ }
+
+ @Test void testAllowListWithSingleClass() {
+ ClassNameFilter cf = ClassNameFilter.of("", "java.lang.String");
+ assertDoesNotThrow(() -> cf.check("java.lang.String"));
+ assertThrows(SecurityException.class,
+ () -> cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ assertThrows(SecurityException.class, () -> cf.check("java.lang.Math"));
+ }
+
+ @Test void testAllowListWithMultipleClasses() {
+ ClassNameFilter cf = ClassNameFilter.of("",
"java.lang.String,java.lang.Math");
+ assertDoesNotThrow(() -> cf.check("java.lang.String"));
+ assertThrows(SecurityException.class, () ->
cf.check("java.lang.StringBuffer"));
+ assertThrows(SecurityException.class,
+ () -> cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ assertDoesNotThrow(() -> cf.check("java.lang.Math"));
+ }
+
+ @Test void testStandardFilter() {
+ // Note that test specific system properties are in effect
+ ClassNameFilter cf = ClassNameFilter.standard();
+ assertDoesNotThrow(() -> cf.check("java.lang.String"));
+ assertDoesNotThrow(() ->
cf.check("org.apache.calcite.adapter.jdbc.JdbcSchema$Factory"));
+ SecurityException x1 =
+ assertThrows(SecurityException.class, () ->
cf.check("com.sun.media.sound.Toolkit"));
+ assertThat(x1.getMessage(), containsString("rejected by the allowlist"));
+ SecurityException x2 =
+ assertThrows(SecurityException.class, () ->
cf.check("javax.naming.InitialContext"));
+ assertThat(x2.getMessage(), containsString("rejected by the denylist"));
+ }
+}
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 4cebda1454..438ec79c3c 100644
--- a/core/src/test/java/org/apache/calcite/model/ModelHandlerTest.java
+++ b/core/src/test/java/org/apache/calcite/model/ModelHandlerTest.java
@@ -31,7 +31,6 @@
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;
@@ -39,6 +38,7 @@
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.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static java.util.Objects.requireNonNull;
@@ -82,7 +82,7 @@ public class ModelHandlerTest {
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.");
+ ClassNameFilter strict = ClassNameFilter.of("java.lang.", "java.");
String model = "inline:{"
+ " version: '1.0',"
+ " defaultSchema: 'X',"
@@ -164,31 +164,20 @@ public class ModelHandlerTest {
"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.");
+ ClassNameFilter a = ClassNameFilter.of("com.evil.", "javax.");
+ ClassNameFilter b = ClassNameFilter.of("com.evil.", "javax.");
assertThat(a, sameInstance(b));
// Different inputs produce different instances.
- ClassNameFilter c = ClassNameFilter.of("com.evil.,com.example.");
+ ClassNameFilter c = ClassNameFilter.of("com.evil.,com.example.", "javax.");
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));
+ assertThrows(SecurityException.class, () -> a.check("com.evil.Payload"));
+ assertDoesNotThrow(() -> a.check("javax.naming.InitialContext"));
}
@Test void testAppendCombinesPatternStrings() {
diff --git a/site/_docs/history.md b/site/_docs/history.md
index c72d6f78d9..9520e7d05f 100644
--- a/site/_docs/history.md
+++ b/site/_docs/history.md
@@ -54,6 +54,11 @@ #### Breaking Changes
filter evaluation now run in Java, and the `arrow-gandiva` dependency is no
longer included in the Arrow module or BOM.
+* [<a
href="https://issues.apache.org/jira/browse/CALCITE-7713">CALCITE-7713</a>]
+Class loading from model files has been disabled by default. Any attempt to
load
+classes from model files will lead to `SecurityException` unless an appropriate
+pattern is set in `calcite.model.classes.allowed` system property.
+
#### New features
{: #new-features-1-43-0}
diff --git a/sqlsh b/sqlsh
index 437060a249..18c9d305a3 100755
--- a/sqlsh
+++ b/sqlsh
@@ -43,6 +43,7 @@ if [ "x$CACHE_SQLLINE_CLASSPATH" != "xY" ] || [ ! -f "$CP" ];
then
fi
VM_OPTS=
-JAVA_OPTS="-Djavax.xml.parsers.DocumentBuilderFactory=com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl
${JAVA_OPTS}"
+# Running sqlsh is explicitly for OS adapter, so we can set the allowed
classes to avoid security exception when loading the model file.
+JAVA_OPTS="-Djavax.xml.parsers.DocumentBuilderFactory=com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl
-Dcalcite.model.classes.allowed=org.apache.calcite.adapter.os. ${JAVA_OPTS}"
exec java $VM_OPTS -cp "${CP}" $JAVA_OPTS
org.apache.calcite.adapter.os.SqlShell "$@"
diff --git a/sqlsh.bat b/sqlsh.bat
index 2ad45c5439..ec4bed1816 100644
--- a/sqlsh.bat
+++ b/sqlsh.bat
@@ -31,7 +31,7 @@ if not defined CACHE_SQLLINE_CLASSPATH (
if exist "%CP%" del "%CP%"
)
if not exist "%CP%" (call "%DIRNAME%\gradlew" --console plain -q
:buildSqllineClasspath)
-
-set
JAVA_OPTS=-Djavax.xml.parsers.DocumentBuilderFactory=com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl
%JAVA_OPTS%
+:: Running sqlsh is explicitly for OS adapter, so we can set the allowed
classes to avoid security exception when loading the model file.
+set
JAVA_OPTS=-Djavax.xml.parsers.DocumentBuilderFactory=com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl
-Dcalcite.model.classes.allowed=org.apache.calcite.adapter.os. %JAVA_OPTS%
java -Xmx1g -cp "%CP%" %JAVA_OPTS% org.apache.calcite.adapter.os.SqlShell %*