Skip to content

Commit 5855cfa

Browse files
committed
[CALCITE-7532] Model usability
1 parent 068c03d commit 5855cfa

4 files changed

Lines changed: 384 additions & 6 deletions

File tree

core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,24 @@ public final class CalciteSystemProperty<T> {
455455
public static final CalciteSystemProperty<Integer> JOIN_SELECTOR_COMPACT_CODE_THRESHOLD =
456456
intProperty("calcite.join.selector.compact.code.threshold", 100);
457457

458+
/**
459+
* Comma-separated patterns to add to the built-in denylist of class
460+
* names that may not be loaded by reflection from a Calcite model
461+
* (user-defined functions, custom schemas/tables, JDBC drivers,
462+
* dialect factories, lattice statistic providers).
463+
*
464+
* <p>Setting this property <em>extends</em> the built-in denylist; the
465+
* built-in entries cannot be removed at runtime.
466+
*
467+
* <p>Pattern syntax: a pattern ending in {@code "."} matches any class
468+
* in that package or its sub-packages; otherwise the pattern matches a
469+
* class name exactly.
470+
*
471+
* @see org.apache.calcite.model.ModelHandler
472+
*/
473+
public static final CalciteSystemProperty<String> MODEL_CLASSES_DENIED =
474+
stringProperty("calcite.model.classes.denied", "");
475+
458476
private static CalciteSystemProperty<Boolean> booleanProperty(String key,
459477
boolean defaultValue) {
460478
// Note that "" -> true (convenient for command-lines flags like '-Dflag')
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to you under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.calcite.model;
18+
19+
import org.apache.calcite.config.CalciteSystemProperty;
20+
21+
import com.google.common.collect.ImmutableList;
22+
23+
import org.checkerframework.checker.nullness.qual.Nullable;
24+
25+
import java.util.concurrent.ConcurrentHashMap;
26+
import java.util.concurrent.ConcurrentMap;
27+
import java.util.function.Predicate;
28+
29+
/**
30+
* Filters class names that may be loaded by reflection from a Calcite
31+
* model: user-defined functions, custom schemas, custom tables, JDBC
32+
* drivers, dialect factories, and lattice statistic providers.
33+
*
34+
* <p>{@link #standard()} returns the filter applied by
35+
* {@link ModelHandler}: the built-in {@link #DEFAULT_DENYLIST} together
36+
* with any patterns from
37+
* {@link CalciteSystemProperty#MODEL_CLASSES_DENIED} (which
38+
* <em>extends</em> the denylist).
39+
*
40+
* <p>The denylist is a comma-separated pattern string. A pattern ending
41+
* in {@code "."} matches any class in that package or its sub-packages;
42+
* otherwise the pattern matches a class name exactly. Whitespace around
43+
* commas is ignored.
44+
*
45+
* <p>The denylist is not a sandbox. Any string passed to a
46+
* {@code className}, {@code factory}, {@code jdbcDriver},
47+
* {@code sqlDialectFactory}, or {@code statisticProvider} field is
48+
* classpath-equivalent; only accept models from trusted sources.
49+
*/
50+
class ClassNameFilter implements Predicate<String> {
51+
/** Built-in denylist: class-name patterns known to enable RCE when
52+
* registered as UDFs, schema/table factories, JDBC drivers, dialect
53+
* factories, or lattice statistic providers. */
54+
static final String DEFAULT_DENYLIST = ""
55+
+ "javax.naming.,"
56+
+ "com.sun.jndi.,"
57+
+ "java.lang.Runtime,"
58+
+ "java.lang.ProcessBuilder,"
59+
+ "java.lang.ProcessImpl,"
60+
+ "java.lang.System,"
61+
+ "java.lang.Class,"
62+
+ "java.lang.reflect.,"
63+
+ "java.lang.invoke.,"
64+
+ "javax.script.,"
65+
+ "bsh.,"
66+
+ "groovy.,"
67+
+ "org.codehaus.groovy.,"
68+
+ "org.python.util.PythonInterpreter,"
69+
+ "org.springframework.expression.,"
70+
+ "org.apache.commons.collections.functors.,"
71+
+ "org.apache.commons.collections4.functors.,"
72+
+ "org.apache.commons.beanutils.,"
73+
+ "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,"
74+
+ "sun.misc.Unsafe,"
75+
+ "jdk.internal.";
76+
77+
/** Cache shared by all factory calls; filters are immutable and small,
78+
* so identical denylist inputs need only be parsed once. */
79+
private static final ConcurrentMap<String, ClassNameFilter> CACHE =
80+
new ConcurrentHashMap<>();
81+
82+
/** The standard filter, built once from the built-in denylist plus
83+
* the {@link CalciteSystemProperty#MODEL_CLASSES_DENIED} extension.
84+
* Initialized via {@link #of} so it shares the same cache. */
85+
private static final ClassNameFilter STANDARD =
86+
of(
87+
append(DEFAULT_DENYLIST,
88+
CalciteSystemProperty.MODEL_CLASSES_DENIED.value()));
89+
90+
private final ImmutableList<String> denylist;
91+
92+
private ClassNameFilter(String denylist) {
93+
this.denylist = parse(denylist);
94+
}
95+
96+
/** Returns the standard filter used by {@link ModelHandler}: the
97+
* built-in {@link #DEFAULT_DENYLIST} (extended by
98+
* {@link CalciteSystemProperty#MODEL_CLASSES_DENIED}). */
99+
static ClassNameFilter standard() {
100+
return STANDARD;
101+
}
102+
103+
/** Returns a filter parsed from a comma-separated denylist pattern
104+
* string; may be empty. Filters are cached, so repeated calls with
105+
* the same argument return the same instance. */
106+
static ClassNameFilter of(String denylist) {
107+
return CACHE.computeIfAbsent(denylist, ClassNameFilter::new);
108+
}
109+
110+
/** Returns whether {@code classRef} is allowed (not on the denylist).
111+
* A null reference is allowed.
112+
*
113+
* <p>{@code classRef} may be a plain class name or the
114+
* {@code "ClassName#STATIC_FIELD"} form accepted by
115+
* {@link org.apache.calcite.avatica.AvaticaUtils#instantiatePlugin};
116+
* the field portion is stripped before matching. */
117+
@Override public boolean test(@Nullable String classRef) {
118+
if (classRef == null) {
119+
return true;
120+
}
121+
String className = stripFieldRef(classRef);
122+
for (String pattern : denylist) {
123+
if (matches(pattern, className)) {
124+
return false;
125+
}
126+
}
127+
return true;
128+
}
129+
130+
/** Throws {@link SecurityException} if {@code classRef} is on the
131+
* denylist. A null reference is a no-op. */
132+
void check(@Nullable String classRef) {
133+
if (classRef == null) {
134+
return;
135+
}
136+
String className = stripFieldRef(classRef);
137+
for (String pattern : denylist) {
138+
if (matches(pattern, className)) {
139+
throw new SecurityException("Class '" + className
140+
+ "' is rejected by the Calcite class-name filter "
141+
+ "(matches denylist pattern '" + pattern + "'). "
142+
+ "If this load is unintended, adjust the model; the "
143+
+ "denylist cannot be loosened at runtime.");
144+
}
145+
}
146+
}
147+
148+
private static String stripFieldRef(String classRef) {
149+
int hash = classRef.indexOf('#');
150+
return hash >= 0 ? classRef.substring(0, hash) : classRef;
151+
}
152+
153+
private static boolean matches(String pattern, String className) {
154+
if (pattern.endsWith(".")) {
155+
return className.startsWith(pattern);
156+
}
157+
return className.equals(pattern);
158+
}
159+
160+
/** Returns the concatenation of two comma-separated pattern strings,
161+
* inserting a comma if needed and tolerating empty inputs. */
162+
static String append(String first, String second) {
163+
if (first.isEmpty()) {
164+
return second;
165+
}
166+
if (second.isEmpty()) {
167+
return first;
168+
}
169+
return first + "," + second;
170+
}
171+
172+
private static ImmutableList<String> parse(String list) {
173+
if (list.isEmpty()) {
174+
return ImmutableList.of();
175+
}
176+
ImmutableList.Builder<String> b = ImmutableList.builder();
177+
for (String s : list.split(",")) {
178+
String trimmed = s.trim();
179+
if (!trimmed.isEmpty()) {
180+
b.add(trimmed);
181+
}
182+
}
183+
return b.build();
184+
}
185+
}

core/src/main/java/org/apache/calcite/model/ModelHandler.java

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,27 @@ public class ModelHandler {
8282
private final Deque<Pair<? extends @Nullable String, SchemaPlus>> schemaStack =
8383
new ArrayDeque<>();
8484
private final String modelUri;
85+
private final ClassNameFilter classNameFilter;
8586
Lattice.@Nullable Builder latticeBuilder;
8687
Lattice.@Nullable TileBuilder tileBuilder;
8788

88-
@SuppressWarnings("method.invocation.invalid")
89+
/** Creates a {@code ModelHandler} that uses the
90+
* {@linkplain ClassNameFilter#standard() standard} class-name filter. */
8991
public ModelHandler(SchemaPlus rootSchema, String uri) throws IOException {
92+
this(rootSchema, uri, ClassNameFilter.standard());
93+
}
94+
95+
/** Creates a {@code ModelHandler} that validates every class loaded
96+
* by reflection from the model against {@code classNameFilter}. Use
97+
* this to apply a stricter (or more permissive) filter than the
98+
* standard one. */
99+
@SuppressWarnings("method.invocation.invalid")
100+
public ModelHandler(SchemaPlus rootSchema, String uri,
101+
ClassNameFilter classNameFilter) throws IOException {
90102
super();
91103
this.modelUri = uri;
92104
this.rootSchema = rootSchema;
105+
this.classNameFilter = classNameFilter;
93106
JsonRoot root;
94107
ObjectMapper mapper;
95108
if (uri.startsWith("inline:")) {
@@ -128,7 +141,12 @@ public static void create(SchemaPlus schema, String functionName,
128141
}
129142

130143
/** Creates and validates a {@link ScalarFunctionImpl}, and adds it to a
131-
* schema. If {@code methodName} is "*", may add more than one function.
144+
* schema, using the {@linkplain ClassNameFilter#standard() standard}
145+
* class-name filter. Kept for backwards compatibility; prefer the
146+
* filter-taking overload of {@code addFunctions}, which lets callers
147+
* supply their own filter.
148+
*
149+
* <p>If {@code methodName} is "*", may add more than one function.
132150
*
133151
* @param schema Schema to add to
134152
* @param functionName Name of function; null to derived from method name
@@ -144,6 +162,16 @@ public static void create(SchemaPlus schema, String functionName,
144162
public static void addFunctions(SchemaPlus schema,
145163
@Nullable String functionName, List<String> unusedPath,
146164
String className, @Nullable String methodName, boolean upCase) {
165+
addFunctions(ClassNameFilter.standard(), schema, functionName, className,
166+
methodName, upCase);
167+
}
168+
169+
/** Creates and validates a {@link ScalarFunctionImpl} and adds it to a
170+
* schema, after asking {@code filter} to accept {@code className}. */
171+
public static void addFunctions(ClassNameFilter filter, SchemaPlus schema,
172+
@Nullable String functionName, String className,
173+
@Nullable String methodName, boolean upCase) {
174+
filter.check(className);
147175
final Class<?> clazz;
148176
try {
149177
clazz = Class.forName(className);
@@ -275,6 +303,7 @@ private void populateSchema(JsonSchema jsonSchema, SchemaPlus schema) {
275303
public void visit(JsonCustomSchema jsonSchema) {
276304
try {
277305
final SchemaPlus parentSchema = currentMutableSchema("sub-schema");
306+
classNameFilter.check(jsonSchema.factory);
278307
final SchemaFactory schemaFactory =
279308
AvaticaUtils.instantiatePlugin(SchemaFactory.class,
280309
jsonSchema.factory);
@@ -329,6 +358,7 @@ protected Map<String, Object> operandMap(@Nullable JsonSchema jsonSchema,
329358

330359
public void visit(JsonJdbcSchema jsonSchema) {
331360
final SchemaPlus parentSchema = currentMutableSchema("jdbc schema");
361+
classNameFilter.check(jsonSchema.jdbcDriver);
332362
final DataSource dataSource =
333363
JdbcSchema.dataSource(jsonSchema.jdbcUrl,
334364
jsonSchema.jdbcDriver,
@@ -340,6 +370,7 @@ public void visit(JsonJdbcSchema jsonSchema) {
340370
JdbcSchema.create(parentSchema, jsonSchema.name, dataSource,
341371
jsonSchema.jdbcCatalog, jsonSchema.jdbcSchema);
342372
} else {
373+
classNameFilter.check(jsonSchema.sqlDialectFactory);
343374
SqlDialectFactory factory =
344375
AvaticaUtils.instantiatePlugin(SqlDialectFactory.class,
345376
jsonSchema.sqlDialectFactory);
@@ -401,6 +432,7 @@ public void visit(JsonLattice jsonLattice) {
401432
latticeBuilder.rowCountEstimate(jsonLattice.rowCountEstimate);
402433
}
403434
if (jsonLattice.statisticProvider != null) {
435+
classNameFilter.check(jsonLattice.statisticProvider);
404436
latticeBuilder.statisticProvider(jsonLattice.statisticProvider);
405437
}
406438
populateLattice(jsonLattice, latticeBuilder);
@@ -421,6 +453,7 @@ private void populateLattice(JsonLattice jsonLattice,
421453
public void visit(JsonCustomTable jsonTable) {
422454
try {
423455
final SchemaPlus schema = currentMutableSchema("table");
456+
classNameFilter.check(jsonTable.factory);
424457
final TableFactory tableFactory =
425458
AvaticaUtils.instantiatePlugin(TableFactory.class,
426459
jsonTable.factory);
@@ -518,10 +551,8 @@ public void visit(JsonFunction jsonFunction) {
518551
// "name" is not required - a class can have several functions
519552
try {
520553
final SchemaPlus schema = currentMutableSchema("function");
521-
final List<String> path =
522-
Util.first(jsonFunction.path, currentSchemaPath());
523-
addFunctions(schema, jsonFunction.name, path, jsonFunction.className,
524-
jsonFunction.methodName, false);
554+
addFunctions(classNameFilter, schema, jsonFunction.name,
555+
jsonFunction.className, jsonFunction.methodName, false);
525556
} catch (Exception e) {
526557
throw new RuntimeException("Error instantiating " + jsonFunction, e);
527558
}

0 commit comments

Comments
 (0)