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

viirya pushed a commit to branch branch-3.5
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-3.5 by this push:
     new 8a01334b7dc4 [SPARK-58658][3.5][CONNECT] Do not return redacted 
configurations from the Config RPC
8a01334b7dc4 is described below

commit 8a01334b7dc4fff475978676ff8a9126e7c2a16a
Author: Aleksandr Efimov <[email protected]>
AuthorDate: Fri Aug 28 00:56:36 2026 -0700

    [SPARK-58658][3.5][CONNECT] Do not return redacted configurations from the 
Config RPC
    
    ### What changes were proposed in this pull request?
    
    Backport of #57892 ([SPARK-58658]) to branch-3.5, requested in 
https://github.com/apache/spark/pull/57892#issuecomment-5336856708.
    
    The cherry-pick does not apply: on 3.5 the handler lives under 
`connector/connect/server/...` and the file has diverged - `handle` resolves 
the session inline through `SparkConnectService.getOrCreateIsolatedSession` 
rather than `SessionHolder.withSession`. The change itself carries over 
unchanged:
    
    - the four read paths - `Get`, `GetWithDefault`, `GetOption`, `GetAll` - 
withhold entries matching `spark.redaction.regex`, each reporting a withheld 
entry the way that same operation already reports an unset key;
    - the pattern is read from the `SparkConf`, not from the session config, 
which the client can write;
    - matching covers the key or the value, following `Utils.redact`;
    - `GetAll` filters before the requested prefix is stripped;
    - `IsModifiable` is unchanged.
    
    One hunk of the original has no counterpart here. `SparkConnectAuthSuite` 
asserts that `spark.connect.authenticate.token` is not readable, and 3.5 has no 
Connect authentication at all, so this backport carries the handler and the new 
`SparkConnectConfigHandlerSuite` only. The suite is adapted to 3.5 APIs: 
`SessionHolder.forTesting` instead of 
`SparkConnectTestUtils.createDummySessionHolder`, 
`SparkConnectService.invalidateAllSessions()`, `getErrorClass` instead of 
`getCondition`, and  [...]
    
    ### Why are the changes needed?
    
    The Config RPC discloses anything sensitive that reached the `SparkConf`, 
whatever put it there, because `SQLConf.mergeSparkConf` copies every entry into 
the session config and no read path applies a denylist. The full rationale, 
including the Kyuubi deployment where a client reads back a ZooKeeper digest 
and an internal pre-shared secret it never held, is in #57892.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, the same change as #57892: a configuration entry whose key or value 
matches `spark.redaction.regex` is no longer returned by the Config RPC, and 
each read operation reports it as if it were unset.
    
    ### How was this patch tested?
    
    ```
    build/sbt "connect/testOnly 
org.apache.spark.sql.connect.service.SparkConnectConfigHandlerSuite"
    ```
    
    8 tests, all passing. With the handler reverted to the branch-3.5 version, 
7 of the 8 fail; the one that still passes is the `IsModifiable` case, whose 
behaviour is deliberately unchanged.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes - Claude Code (Opus 5) was used for code reading, drafting and review. 
Every line was reviewed by the author, who takes responsibility for the patch.
    
    Closes #58104 from alexandrefimov/SPARK-58658-3.5.
    
    Authored-by: Aleksandr Efimov <[email protected]>
    Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
 .../service/SparkConnectConfigHandler.scala        |  76 +++++++--
 .../service/SparkConnectConfigHandlerSuite.scala   | 171 +++++++++++++++++++++
 2 files changed, 234 insertions(+), 13 deletions(-)

diff --git 
a/connector/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandler.scala
 
b/connector/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandler.scala
index 38fd88297f35..4bf2362476ed 100644
--- 
a/connector/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandler.scala
+++ 
b/connector/connect/server/src/main/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandler.scala
@@ -18,12 +18,15 @@
 package org.apache.spark.sql.connect.service
 
 import scala.collection.JavaConverters._
+import scala.util.matching.Regex
 
 import io.grpc.stub.StreamObserver
 
 import org.apache.spark.connect.proto
 import org.apache.spark.internal.Logging
+import org.apache.spark.internal.config.SECRET_REDACTION_PATTERN
 import org.apache.spark.sql.RuntimeConfig
+import org.apache.spark.sql.errors.QueryExecutionErrors
 import org.apache.spark.sql.internal.SQLConf
 
 class SparkConnectConfigHandler(responseObserver: 
StreamObserver[proto.ConfigResponse])
@@ -35,17 +38,24 @@ class SparkConnectConfigHandler(responseObserver: 
StreamObserver[proto.ConfigRes
         .getOrCreateIsolatedSession(request.getUserContext.getUserId, 
request.getSessionId)
         .session
 
+    // Read the pattern from the SparkConf rather than from the session 
config. The session config
+    // is writable by the client, so taking the pattern from there would let a 
client widen its own
+    // view of the configuration before reading it back.
+    val redactionPattern = 
session.sparkContext.conf.get(SECRET_REDACTION_PATTERN)
     val builder = request.getOperation.getOpTypeCase match {
       case proto.ConfigRequest.Operation.OpTypeCase.SET =>
         handleSet(request.getOperation.getSet, session.conf)
       case proto.ConfigRequest.Operation.OpTypeCase.GET =>
-        handleGet(request.getOperation.getGet, session.conf)
+        handleGet(request.getOperation.getGet, session.conf, redactionPattern)
       case proto.ConfigRequest.Operation.OpTypeCase.GET_WITH_DEFAULT =>
-        handleGetWithDefault(request.getOperation.getGetWithDefault, 
session.conf)
+        handleGetWithDefault(
+          request.getOperation.getGetWithDefault,
+          session.conf,
+          redactionPattern)
       case proto.ConfigRequest.Operation.OpTypeCase.GET_OPTION =>
-        handleGetOption(request.getOperation.getGetOption, session.conf)
+        handleGetOption(request.getOperation.getGetOption, session.conf, 
redactionPattern)
       case proto.ConfigRequest.Operation.OpTypeCase.GET_ALL =>
-        handleGetAll(request.getOperation.getGetAll, session.conf)
+        handleGetAll(request.getOperation.getGetAll, session.conf, 
redactionPattern)
       case proto.ConfigRequest.Operation.OpTypeCase.UNSET =>
         handleUnset(request.getOperation.getUnset, session.conf)
       case proto.ConfigRequest.Operation.OpTypeCase.IS_MODIFIABLE =>
@@ -72,10 +82,15 @@ class SparkConnectConfigHandler(responseObserver: 
StreamObserver[proto.ConfigRes
 
   private def handleGet(
       operation: proto.ConfigRequest.Get,
-      conf: RuntimeConfig): proto.ConfigResponse.Builder = {
+      conf: RuntimeConfig,
+      redactionPattern: Regex): proto.ConfigResponse.Builder = {
     val builder = proto.ConfigResponse.newBuilder()
     operation.getKeysList.asScala.iterator.foreach { key =>
       val value = conf.get(key)
+      if (SparkConnectConfigHandler.isRedacted(key, Option(value), 
redactionPattern)) {
+        // This operation reports an unset key by failing, so a redacted key 
fails the same way.
+        throw QueryExecutionErrors.sqlConfigNotFoundError(key)
+      }
       builder.addPairs(SparkConnectConfigHandler.toProtoKeyValue(key, 
Option(value)))
       getWarning(key).foreach(builder.addWarnings)
     }
@@ -84,12 +99,19 @@ class SparkConnectConfigHandler(responseObserver: 
StreamObserver[proto.ConfigRes
 
   private def handleGetWithDefault(
       operation: proto.ConfigRequest.GetWithDefault,
-      conf: RuntimeConfig): proto.ConfigResponse.Builder = {
+      conf: RuntimeConfig,
+      redactionPattern: Regex): proto.ConfigResponse.Builder = {
     val builder = proto.ConfigResponse.newBuilder()
     operation.getPairsList.asScala.iterator.foreach { pair =>
       val (key, default) = SparkConnectConfigHandler.toKeyValue(pair)
-      val value = conf.get(key, default.orNull)
-      builder.addPairs(SparkConnectConfigHandler.toProtoKeyValue(key, 
Option(value)))
+      val stored = Option(conf.get(key, default.orNull))
+      // A redacted entry falls back to the caller's default, as an unset key 
does.
+      val value = if (SparkConnectConfigHandler.isRedacted(key, stored, 
redactionPattern)) {
+        default
+      } else {
+        stored
+      }
+      builder.addPairs(SparkConnectConfigHandler.toProtoKeyValue(key, value))
       getWarning(key).foreach(builder.addWarnings)
     }
     builder
@@ -97,10 +119,16 @@ class SparkConnectConfigHandler(responseObserver: 
StreamObserver[proto.ConfigRes
 
   private def handleGetOption(
       operation: proto.ConfigRequest.GetOption,
-      conf: RuntimeConfig): proto.ConfigResponse.Builder = {
+      conf: RuntimeConfig,
+      redactionPattern: Regex): proto.ConfigResponse.Builder = {
     val builder = proto.ConfigResponse.newBuilder()
     operation.getKeysList.asScala.iterator.foreach { key =>
-      val value = conf.getOption(key)
+      val stored = conf.getOption(key)
+      val value = if (SparkConnectConfigHandler.isRedacted(key, stored, 
redactionPattern)) {
+        None
+      } else {
+        stored
+      }
       builder.addPairs(SparkConnectConfigHandler.toProtoKeyValue(key, value))
       getWarning(key).foreach(builder.addWarnings)
     }
@@ -109,15 +137,22 @@ class SparkConnectConfigHandler(responseObserver: 
StreamObserver[proto.ConfigRes
 
   private def handleGetAll(
       operation: proto.ConfigRequest.GetAll,
-      conf: RuntimeConfig): proto.ConfigResponse.Builder = {
+      conf: RuntimeConfig,
+      redactionPattern: Regex): proto.ConfigResponse.Builder = {
     val builder = proto.ConfigResponse.newBuilder()
+    // Drop redacted entries before the prefix is stripped below. Matching 
afterwards would let a
+    // GetAll with prefix `spark.my.secret.` return `spark.my.secret.value` as 
`value`, which no
+    // longer matches the pattern.
+    val visible = conf.getAll.iterator.filterNot { case (key, value) =>
+      SparkConnectConfigHandler.isRedacted(key, Option(value), 
redactionPattern)
+    }
     val results = if (operation.hasPrefix) {
       val prefix = operation.getPrefix
-      conf.getAll.iterator
+      visible
         .filter { case (key, _) => key.startsWith(prefix) }
         .map { case (key, value) => (key.substring(prefix.length), value) }
     } else {
-      conf.getAll.iterator
+      visible
     }
     results.foreach { case (key, value) =>
       builder.addPairs(SparkConnectConfigHandler.toProtoKeyValue(key, 
Option(value)))
@@ -162,6 +197,21 @@ object SparkConnectConfigHandler {
 
   private[connect] val unsupportedConfigurations = 
Set("spark.sql.execution.arrow.enabled")
 
+  /**
+   * Whether a configuration entry is considered sensitive and must not be 
disclosed by the Config
+   * RPC. Follows `spark.redaction.regex` and matches the key or the value, 
the way `Utils.redact`
+   * does for the environment UI and event logs and `SetCommand` does for 
`SET`. Matching the
+   * value is what covers a secret carried by an innocuous key, such as a 
password inside a JDBC
+   * URL.
+   */
+  private[connect] def isRedacted(
+      key: String,
+      value: Option[String],
+      redactionPattern: Regex): Boolean = {
+    redactionPattern.findFirstIn(key).isDefined ||
+    value.exists(redactionPattern.findFirstIn(_).isDefined)
+  }
+
   def toKeyValue(pair: proto.KeyValue): (String, Option[String]) = {
     val key = pair.getKey
     val value = if (pair.hasValue) {
diff --git 
a/connector/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandlerSuite.scala
 
b/connector/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandlerSuite.scala
new file mode 100644
index 000000000000..18774b089a65
--- /dev/null
+++ 
b/connector/connect/server/src/test/scala/org/apache/spark/sql/connect/service/SparkConnectConfigHandlerSuite.scala
@@ -0,0 +1,171 @@
+/*
+ * 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.spark.sql.connect.service
+
+import scala.collection.JavaConverters._
+import scala.concurrent.Promise
+import scala.concurrent.duration._
+
+import io.grpc.stub.StreamObserver
+
+import org.apache.spark.SparkNoSuchElementException
+import org.apache.spark.connect.proto
+import org.apache.spark.internal.config.SECRET_REDACTION_PATTERN
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.util.ThreadUtils
+
+class SparkConnectConfigHandlerSuite extends SharedSparkSession {
+
+  // Matches the default spark.redaction.regex on "password".
+  private val secretKey = "spark.test.connect.password"
+  private val secretValue = "hunter2"
+  private val plainKey = "spark.test.connect.endpoint"
+  private val plainValue = "localhost:15002"
+
+  protected override def afterEach(): Unit = {
+    super.afterEach()
+    SparkConnectService.invalidateAllSessions()
+  }
+
+  private def sendConfigRequest(
+      sessionHolder: SessionHolder,
+      customize: proto.ConfigRequest.Operation.Builder => Unit): 
proto.ConfigResponse = {
+    val operation = proto.ConfigRequest.Operation.newBuilder()
+    customize(operation)
+    val request = proto.ConfigRequest
+      .newBuilder()
+      
.setUserContext(proto.UserContext.newBuilder().setUserId(sessionHolder.userId).build())
+      .setSessionId(sessionHolder.sessionId)
+      .setOperation(operation)
+      .build()
+    val responseObserver = new ConfigResponseObserver()
+    new SparkConnectConfigHandler(responseObserver).handle(request)
+    ThreadUtils.awaitResult(responseObserver.promise.future, 10.seconds)
+  }
+
+  /** The returned pairs, with an absent value mapped to None. */
+  private def pairs(response: proto.ConfigResponse): Map[String, 
Option[String]] = {
+    response.getPairsList.asScala
+      .map(pair => pair.getKey -> (if (pair.hasValue) Some(pair.getValue) else 
None))
+      .toMap
+  }
+
+  test("GetAll does not return keys matching the redaction pattern") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    withSQLConf(secretKey -> secretValue, plainKey -> plainValue) {
+      val returned = pairs(sendConfigRequest(sessionHolder, 
_.getGetAllBuilder))
+      assert(!returned.contains(secretKey))
+      assert(returned(plainKey) === Some(plainValue))
+    }
+  }
+
+  test("GetAll matches the redaction pattern on the full key, not the prefixed 
one") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    // Stripping the prefix leaves "value", which no longer matches the 
pattern. The filter has to
+    // run before the prefix comes off.
+    val prefix = "spark.test.connect.secret."
+    withSQLConf(prefix + "value" -> secretValue) {
+      val returned = pairs(sendConfigRequest(sessionHolder, 
_.getGetAllBuilder.setPrefix(prefix)))
+      assert(returned.isEmpty)
+    }
+  }
+
+  test("a secret carried by an innocuous key is withheld too") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    // `SET spark.test.connect.jdbc.url` already masks this value, because 
SetCommand redacts
+    // through Utils.redact, which matches the value as well. The Config RPC 
has to agree.
+    val urlKey = "spark.test.connect.jdbc.url"
+    val urlValue = "jdbc:postgresql://db:5432/app?user=app&password=hunter2"
+    withSQLConf(urlKey -> urlValue) {
+      assert(!pairs(sendConfigRequest(sessionHolder, 
_.getGetAllBuilder)).contains(urlKey))
+      val returned =
+        pairs(sendConfigRequest(sessionHolder, 
_.getGetOptionBuilder.addKeys(urlKey)))
+      assert(returned(urlKey) === None)
+    }
+  }
+
+  test("Get reports a redacted key the way it reports an unset one") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    withSQLConf(secretKey -> secretValue) {
+      val redacted = intercept[SparkNoSuchElementException] {
+        sendConfigRequest(sessionHolder, _.getGetBuilder.addKeys(secretKey))
+      }
+      val unset = intercept[SparkNoSuchElementException] {
+        sendConfigRequest(sessionHolder, 
_.getGetBuilder.addKeys("spark.test.connect.absent"))
+      }
+      assert(redacted.getErrorClass === unset.getErrorClass)
+    }
+  }
+
+  test("GetOption returns no value for a redacted key") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    withSQLConf(secretKey -> secretValue, plainKey -> plainValue) {
+      val returned = pairs(
+        sendConfigRequest(
+          sessionHolder,
+          _.getGetOptionBuilder.addKeys(secretKey).addKeys(plainKey)))
+      assert(returned(secretKey) === None)
+      assert(returned(plainKey) === Some(plainValue))
+    }
+  }
+
+  test("GetWithDefault returns the caller's default for a redacted key") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    withSQLConf(secretKey -> secretValue) {
+      val returned = pairs(
+        sendConfigRequest(
+          sessionHolder,
+          
_.getGetWithDefaultBuilder.addPairsBuilder().setKey(secretKey).setValue("fallback")))
+      assert(returned(secretKey) === Some("fallback"))
+    }
+  }
+
+  test("the redaction pattern is not read from the session config") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    // Both of these are reachable through the Set operation: the legacy flag 
is what otherwise
+    // stops a client from writing spark.redaction.regex into its own session 
config.
+    withSQLConf(
+      SQLConf.SET_COMMAND_REJECTS_SPARK_CORE_CONFS.key -> "false",
+      secretKey -> secretValue) {
+      spark.conf.set(SECRET_REDACTION_PATTERN.key, "matches-no-key")
+      try {
+        val returned = pairs(sendConfigRequest(sessionHolder, 
_.getGetAllBuilder))
+        assert(!returned.contains(secretKey))
+      } finally {
+        spark.conf.unset(SECRET_REDACTION_PATTERN.key)
+      }
+    }
+  }
+
+  test("IsModifiable still answers for a redacted key") {
+    val sessionHolder = SessionHolder.forTesting(spark)
+    withSQLConf(secretKey -> secretValue) {
+      // Whether a key is modifiable is a property of the key, so it discloses 
no value.
+      val returned =
+        pairs(sendConfigRequest(sessionHolder, 
_.getIsModifiableBuilder.addKeys(secretKey)))
+      assert(returned(secretKey) === Some("false"))
+    }
+  }
+}
+
+private class ConfigResponseObserver extends 
StreamObserver[proto.ConfigResponse] {
+  val promise: Promise[proto.ConfigResponse] = Promise()
+  override def onNext(value: proto.ConfigResponse): Unit = 
promise.success(value)
+  override def onError(t: Throwable): Unit = promise.failure(t)
+  override def onCompleted(): Unit = {}
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to