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

github-actions[bot] pushed a commit to branch site
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/site by this push:
     new 746232a52f [CALCITE-7773] Kafka adapter should validate class-naming 
keys in the "consumer.params" operand
746232a52f is described below

commit 746232a52fefe96bed30c533491557970a528f43
Author: Ruben Quesada Lopez <[email protected]>
AuthorDate: Fri Sep 11 17:25:01 2026 +0100

    [CALCITE-7773] Kafka adapter should validate class-naming keys in the 
"consumer.params" operand
---
 .../calcite/config/CalciteSystemProperty.java      |  23 +++++
 .../calcite/adapter/kafka/KafkaTableFactory.java   |  66 ++++++++++++-
 .../adapter/kafka/KafkaConsumerParamsTest.java     | 109 +++++++++++++++++++++
 site/_docs/history.md                              |  11 +++
 4 files changed, 207 insertions(+), 2 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 b5f9407509..6b3adc32de 100644
--- a/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
+++ b/core/src/main/java/org/apache/calcite/config/CalciteSystemProperty.java
@@ -497,6 +497,29 @@ public final class CalciteSystemProperty<T> {
   public static final CalciteSystemProperty<String> MODEL_BASE_DIRECTORY =
       stringProperty("calcite.model.baseDirectory", "");
 
+  /**
+   * Whether the Kafka adapter forwards the {@code consumer.params} model
+   * operand to the Kafka client without filtering it.
+   *
+   * <p>The operand comes from the (query-author-supplied) model, and several
+   * Kafka consumer config keys ({@code key.deserializer},
+   * {@code value.deserializer}, {@code interceptor.classes},
+   * {@code metric.reporters}, {@code partition.assignment.strategy},
+   * {@code sasl.jaas.config}, {@code security.providers}, and any key
+   * ending in {@code .class} or {@code .classes}) cause the Kafka client
+   * to load and initialize classes named in the operand.
+   *
+   * <p>By default (empty / {@code false}) the Kafka adapter rejects those
+   * keys in {@code consumer.params}, and only accepts
+   * {@code key.deserializer}/{@code value.deserializer} after checking that
+   * the named class implements
+   * {@code org.apache.kafka.common.serialization.Deserializer} without
+   * initializing it. Set this property to {@code true} only when models
+   * come exclusively from trusted operators.
+   */
+  public static final CalciteSystemProperty<Boolean> 
KAFKA_CONSUMER_PARAMS_TRUSTED =
+      booleanProperty("calcite.kafka.consumer.params.trusted", false);
+
   /**
    * Maximum number of decimal digits that the plain-notation expansion of a 
{@code DECIMAL}
    * literal may contain.
diff --git 
a/kafka/src/main/java/org/apache/calcite/adapter/kafka/KafkaTableFactory.java 
b/kafka/src/main/java/org/apache/calcite/adapter/kafka/KafkaTableFactory.java
index 2d6c3a2a10..ca2cce7c48 100644
--- 
a/kafka/src/main/java/org/apache/calcite/adapter/kafka/KafkaTableFactory.java
+++ 
b/kafka/src/main/java/org/apache/calcite/adapter/kafka/KafkaTableFactory.java
@@ -17,12 +17,15 @@
 package org.apache.calcite.adapter.kafka;
 
 import org.apache.calcite.avatica.AvaticaUtils;
+import org.apache.calcite.config.CalciteSystemProperty;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.schema.SchemaPlus;
 import org.apache.calcite.schema.TableFactory;
 
 import org.apache.kafka.clients.consumer.Consumer;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.clients.consumer.OffsetResetStrategy;
+import org.apache.kafka.common.serialization.Deserializer;
 
 import org.checkerframework.checker.nullness.qual.Nullable;
 
@@ -59,8 +62,10 @@ public KafkaTableFactory() {
     tableOptionBuilder.setRowConverter(rowConverter);
 
     if (operand.containsKey(KafkaTableConstants.SCHEMA_CONSUMER_PARAMS)) {
-      tableOptionBuilder.setConsumerParams(
-          (Map<String, String>) 
operand.get(KafkaTableConstants.SCHEMA_CONSUMER_PARAMS));
+      final Map<String, String> consumerParams =
+          (Map<String, String>) 
operand.get(KafkaTableConstants.SCHEMA_CONSUMER_PARAMS);
+      checkConsumerParams(consumerParams);
+      tableOptionBuilder.setConsumerParams(consumerParams);
     }
     if (operand.containsKey(KafkaTableConstants.SCHEMA_CUST_CONSUMER)) {
       String custConsumerClass = (String) 
operand.get(KafkaTableConstants.SCHEMA_CUST_CONSUMER);
@@ -84,4 +89,61 @@ public KafkaTableFactory() {
 
     return new KafkaStreamTable(tableOptionBuilder);
   }
+
+  /** Rejects entries of the {@code consumer.params} operand that would make
+   * the Kafka client load classes named by the model author, unless system
+   * property
+   * {@link CalciteSystemProperty#KAFKA_CONSUMER_PARAMS_TRUSTED} is set. Key
+   * and value deserializers are allowed if the named class implements
+   * {@link Deserializer}. */
+  static void checkConsumerParams(Map<String, String> consumerParams) {
+    if (CalciteSystemProperty.KAFKA_CONSUMER_PARAMS_TRUSTED.value()) {
+      return;
+    }
+    for (Map.Entry<String, String> entry : consumerParams.entrySet()) {
+      final String key = entry.getKey();
+      if (key.equals(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG)
+          || key.equals(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG)) {
+        checkDeserializer(key, entry.getValue());
+      } else if (isClassLoadingParam(key)) {
+        throw new SecurityException("Consumer parameter '" + key
+            + "' can make the Kafka client load and run classes named in"
+            + " the model, so it is not allowed in the 'consumer.params'"
+            + " operand; configure it on the operator side, or set system"
+            + " property 'calcite.kafka.consumer.params.trusted' to 'true'"
+            + " if models are trusted");
+      }
+    }
+  }
+
+  /** Returns whether a Kafka consumer configuration key causes classes
+   * named in its value (or, for JAAS, in the login-module configuration
+   * text) to be loaded and instantiated by the Kafka client. */
+  private static boolean isClassLoadingParam(String key) {
+    return key.endsWith(".class")
+        || key.endsWith(".classes")
+        || key.equals("sasl.jaas.config")
+        || key.equals("security.providers")
+        || key.equals("metric.reporters")
+        || key.equals("partition.assignment.strategy");
+  }
+
+  /** Checks that a deserializer named in {@code consumer.params} implements
+   * {@link Deserializer} before the Kafka client loads (and initializes) it. 
*/
+  private static void checkDeserializer(String key, String className) {
+    final Class<?> klass;
+    try {
+      klass =
+          Class.forName(className, false,
+              KafkaTableFactory.class.getClassLoader());
+    } catch (ClassNotFoundException e) {
+      throw new SecurityException("Deserializer class '" + className
+          + "' given as consumer parameter '" + key + "' not found", e);
+    }
+    if (!Deserializer.class.isAssignableFrom(klass)) {
+      throw new SecurityException("Class '" + className
+          + "' given as consumer parameter '" + key
+          + "' does not implement " + Deserializer.class.getName());
+    }
+  }
 }
diff --git 
a/kafka/src/test/java/org/apache/calcite/adapter/kafka/KafkaConsumerParamsTest.java
 
b/kafka/src/test/java/org/apache/calcite/adapter/kafka/KafkaConsumerParamsTest.java
new file mode 100644
index 0000000000..8f24c70c12
--- /dev/null
+++ 
b/kafka/src/test/java/org/apache/calcite/adapter/kafka/KafkaConsumerParamsTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.adapter.kafka;
+
+import org.apache.kafka.common.serialization.StringDeserializer;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Tests for the {@code consumer.params} filtering in
+ * {@link KafkaTableFactory}: parameters that make the Kafka client load
+ * classes named in the model must be rejected by default.
+ */
+class KafkaConsumerParamsTest {
+  /** Set by {@link NotADeserializer}'s static initializer. A class named as
+   * a deserializer in {@code consumer.params} must not have any of its code
+   * run unless it implements {@code Deserializer}. */
+  static final AtomicBoolean NOT_A_DESERIALIZER_INITIALIZED = new 
AtomicBoolean(false);
+
+  /** Stands in for an arbitrary untrusted class named as a deserializer. */
+  public static class NotADeserializer {
+    static {
+      NOT_A_DESERIALIZER_INITIALIZED.set(true);
+    }
+  }
+
+  private static Map<String, String> params(String key, String value) {
+    final Map<String, String> params = new HashMap<>();
+    params.put(key, value);
+    return params;
+  }
+
+  @Test void testHarmlessConnectivityParamsAllowed() {
+    final Map<String, String> params = new HashMap<>();
+    params.put("group.id", "g");
+    params.put("max.poll.records", "100");
+    params.put("security.protocol", "PLAINTEXT");
+    assertDoesNotThrow(() -> KafkaTableFactory.checkConsumerParams(params));
+  }
+
+  @Test void testJaasConfigRejected() {
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            KafkaTableFactory.checkConsumerParams(
+                params("sasl.jaas.config",
+                    "com.sun.security.auth.module.JndiLoginModule required"
+                        + " user.provider.url=\"ldap://example/o\";";)));
+    assertThat(e.getMessage(), containsString("sasl.jaas.config"));
+  }
+
+  @Test void testInterceptorClassesRejected() {
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            KafkaTableFactory.checkConsumerParams(
+                params("interceptor.classes", "com.bad.Interceptor")));
+    assertThat(e.getMessage(), containsString("interceptor.classes"));
+  }
+
+  @Test void testCallbackHandlerClassRejected() {
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            KafkaTableFactory.checkConsumerParams(
+                params("sasl.client.callback.handler.class", 
"com.bad.Handler")));
+    assertThat(e.getMessage(), 
containsString("sasl.client.callback.handler.class"));
+  }
+
+  /** A deserializer that is not actually a {@code Deserializer} is rejected
+   * without being initialized. */
+  @Test void testNonDeserializerClassRejectedUninitialized() {
+    SecurityException e =
+        assertThrows(SecurityException.class, () ->
+            KafkaTableFactory.checkConsumerParams(
+                params("value.deserializer", 
NotADeserializer.class.getName())));
+    assertThat(e.getMessage(), 
containsString(NotADeserializer.class.getName()));
+    assertThat("static initializer of a rejected class must not run",
+        NOT_A_DESERIALIZER_INITIALIZED.get(), is(false));
+  }
+
+  /** A genuine {@code Deserializer} implementation is still accepted. */
+  @Test void testRealDeserializerAllowed() {
+    assertDoesNotThrow(() ->
+        KafkaTableFactory.checkConsumerParams(
+            params("key.deserializer", StringDeserializer.class.getName())));
+  }
+}
diff --git a/site/_docs/history.md b/site/_docs/history.md
index 32a7f0d4fb..a2c769c234 100644
--- a/site/_docs/history.md
+++ b/site/_docs/history.md
@@ -63,6 +63,17 @@ #### Breaking Changes
 `KafkaTableFactory` row converter operand now requires the presence of a 
`public`
 constructor for instantiating the class.
 
+* [<a 
href="https://issues.apache.org/jira/browse/CALCITE-7773";>CALCITE-7773</a>]
+`KafkaTableFactory` now validates the `consumer.params` operand at table
+creation. Keys whose values name a class the Kafka client would load and
+initialize (deserializers, interceptors, metric reporters, partition
+assignment strategies, callback handlers, JAAS/security providers, and any
+key ending in `.class` or `.classes`) are rejected with `SecurityException`.
+`key.deserializer` and `value.deserializer` remain accepted after an
+interface check. Set `-Dcalcite.kafka.consumer.params.trusted=true` to
+restore the previous forwarding behavior when models are entirely
+operator-supplied.
+
 * [<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

Reply via email to