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

pjfanning pushed a commit to branch 1.7.x
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/1.7.x by this push:
     new 274ec0d878 fix: don't resolve HOCON includes in config that arrived in 
a message (#3505) (#3518)
274ec0d878 is described below

commit 274ec0d878028634393598c8405eb917c0b59298
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 4 11:26:01 2026 +0100

    fix: don't resolve HOCON includes in config that arrived in a message 
(#3505) (#3518)
    
    * fix: don't resolve HOCON includes in config that arrived in a message
    
    Motivation:
    Three sites parse HOCON that came off the wire with the default parse
    options: InternalClusterAction.InitJoin and InitJoinAck in
    ClusterMessageSerializer, and the Config payload in MiscMessageSerializer.
    HOCON include directives are resolved by the parser rather than by
    resolve(), so include file(...) and include classpath(...) read from the
    local filesystem and classpath and include url(...) performs an outbound
    request, all while deserializing a peer's message. InitJoin is accepted
    from a node that has not joined, in ClusterDaemon's uninitialized state.
    
    Modification:
    Add WireConfig (@InternalApi), which parses with a ConfigIncluder that
    resolves every include to an empty object, and route the three sites
    through it. The includer implements ConfigIncluderFile, ConfigIncluderURL
    and ConfigIncluderClasspath as well as ConfigIncluder: the parser falls
    back to its own handling, which does read the resource, for any of the
    typed forms the configured includer does not implement.
    
    Every serializer writes config with ConfigRenderOptions.concise, which
    renders JSON and cannot produce an include, so a well-behaved sender is
    unaffected.
    
    Result:
    Deserializing a message no longer reads local files or issues outbound
    requests on behalf of the sender.
    
    * Update WireConfigSpec.scala
---
 .../pekko/serialization/WireConfigSpec.scala       | 93 ++++++++++++++++++++++
 .../apache/pekko/serialization/WireConfig.scala    | 82 +++++++++++++++++++
 .../protobuf/ClusterMessageSerializer.scala        |  4 +-
 .../protobuf/ClusterMessageSerializerSpec.scala    | 35 ++++++++
 .../serialization/MiscMessageSerializer.scala      | 10 ++-
 .../serialization/MiscMessageSerializerSpec.scala  | 21 ++++-
 6 files changed, 240 insertions(+), 5 deletions(-)

diff --git 
a/actor-tests/src/test/scala/org/apache/pekko/serialization/WireConfigSpec.scala
 
b/actor-tests/src/test/scala/org/apache/pekko/serialization/WireConfigSpec.scala
new file mode 100644
index 0000000000..cf0ab9831c
--- /dev/null
+++ 
b/actor-tests/src/test/scala/org/apache/pekko/serialization/WireConfigSpec.scala
@@ -0,0 +1,93 @@
+/*
+ * 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.pekko.serialization
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.{ Files, Path }
+
+import org.apache.pekko.util.ccompat.JavaConverters._
+
+import org.apache.pekko.testkit.PekkoSpec
+
+import com.typesafe.config.{ ConfigFactory, ConfigRenderOptions }
+
+class WireConfigSpec extends PekkoSpec {
+
+  // a file the parser must not read when a message asks it to
+  private val secretFile: Path = {
+    val f = Files.createTempFile("wire-config-spec", ".conf")
+    Files.write(f, "secret = leaked".getBytes(StandardCharsets.UTF_8))
+    f
+  }
+  private val filePath = secretFile.toAbsolutePath.toString
+  private val fileUrl = secretFile.toUri.toString
+
+  override def afterTermination(): Unit = Files.deleteIfExists(secretFile)
+
+  "WireConfig" must {
+
+    "parse ordinary HOCON" in {
+      val config = WireConfig.parseString("a = 1\nb { c = two }")
+      config.getInt("a") should ===(1)
+      config.getString("b.c") should ===("two")
+    }
+
+    "parse what a serializer writes" in {
+      // every serializer renders config with ConfigRenderOptions.concise
+      val rendered =
+        ConfigFactory.parseString("pekko.cluster.roles = [a, 
b]").root.render(ConfigRenderOptions.concise())
+      
WireConfig.parseString(rendered).getStringList("pekko.cluster.roles").asScala.toList
 should ===(List("a", "b"))
+    }
+
+    "not read a file named by an include" in {
+      // s"...\"..." is not valid Scala 2.12, hence the triple quotes
+      val config = WireConfig.parseString(s"""include file("$filePath")
+                                             |a = 1""".stripMargin)
+      config.hasPath("secret") should ===(false)
+      config.getInt("a") should ===(1)
+    }
+
+    "not read a file named by a required include" in {
+      WireConfig
+        .parseString(s"""include required(file("$filePath"))
+                        |a = 1""".stripMargin)
+        .hasPath("secret") should ===(false)
+    }
+
+    "not fetch a URL named by an include" in {
+      // a file: URL stands in for an outbound request, so the test needs no 
network
+      val config = WireConfig.parseString(s"""include url("$fileUrl")
+                                             |a = 1""".stripMargin)
+      config.hasPath("secret") should ===(false)
+      config.getInt("a") should ===(1)
+    }
+
+    "not read a resource named by a classpath include" in {
+      // reference.conf is on the test classpath, so the default includer 
would pull it in
+      WireConfig.parseString("include classpath(\"reference.conf\")\na = 
1").hasPath("pekko.version") should ===(false)
+    }
+
+    "differ from the default parser, which does resolve all three" in {
+      // guards the premise of the tests above: these directives really do 
resolve without the
+      // includer, so those tests are checking the change rather than an inert 
directive
+      ConfigFactory.parseString(s"""include 
file("$filePath")""").hasPath("secret") should ===(true)
+      ConfigFactory.parseString(s"""include 
url("$fileUrl")""").hasPath("secret") should ===(true)
+      ConfigFactory.parseString("include 
classpath(\"reference.conf\")").hasPath("pekko.version") should ===(true)
+    }
+  }
+}
diff --git 
a/actor/src/main/scala/org/apache/pekko/serialization/WireConfig.scala 
b/actor/src/main/scala/org/apache/pekko/serialization/WireConfig.scala
new file mode 100644
index 0000000000..673f56df34
--- /dev/null
+++ b/actor/src/main/scala/org/apache/pekko/serialization/WireConfig.scala
@@ -0,0 +1,82 @@
+/*
+ * 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.pekko.serialization
+
+import java.io.File
+import java.net.URL
+
+import org.apache.pekko.annotation.InternalApi
+
+import com.typesafe.config.{
+  Config,
+  ConfigFactory,
+  ConfigIncludeContext,
+  ConfigIncluder,
+  ConfigIncluderClasspath,
+  ConfigIncluderFile,
+  ConfigIncluderURL,
+  ConfigObject,
+  ConfigParseOptions
+}
+
+/**
+ * INTERNAL API
+ *
+ * Parsing of HOCON that arrived in a message.
+ *
+ * HOCON `include` directives are resolved by the parser, not by `resolve()`, 
so parsing a
+ * string with the default includer reads whatever it names: `include 
file(...)` and
+ * `include classpath(...)` read from the local filesystem and classpath, and
+ * `include url(...)` performs an outbound request. None of that belongs on a 
path whose
+ * input came from a peer.
+ *
+ * Every serializer writes config with `ConfigRenderOptions.concise`, which 
renders JSON and
+ * cannot produce an `include`, so dropping them costs a well-behaved sender 
nothing.
+ */
+@InternalApi private[pekko] object WireConfig {
+
+  /**
+   * Resolves every form of `include` to an empty object.
+   *
+   * All four interfaces have to be implemented: the parser dispatches 
`include file(...)`,
+   * `include url(...)` and `include classpath(...)` to the typed methods and 
falls back to
+   * its own default handling — which does read the resource — when the 
configured includer
+   * does not implement the matching interface. Only bare `include "..."` goes 
to `include`.
+   */
+  private object NoIncludes
+      extends ConfigIncluder
+      with ConfigIncluderFile
+      with ConfigIncluderURL
+      with ConfigIncluderClasspath {
+
+    private def empty: ConfigObject = ConfigFactory.empty().root()
+
+    override def withFallback(fallback: ConfigIncluder): ConfigIncluder = this
+    override def include(context: ConfigIncludeContext, what: String): 
ConfigObject = empty
+    override def includeFile(context: ConfigIncludeContext, what: File): 
ConfigObject = empty
+    override def includeURL(context: ConfigIncludeContext, what: URL): 
ConfigObject = empty
+    override def includeResources(context: ConfigIncludeContext, what: 
String): ConfigObject = empty
+  }
+
+  private val parseOptions: ConfigParseOptions = 
ConfigParseOptions.defaults().setIncluder(NoIncludes)
+
+  /**
+   * Like `ConfigFactory.parseString`, but with `include` directives resolved 
to nothing.
+   */
+  def parseString(hocon: String): Config = ConfigFactory.parseString(hocon, 
parseOptions)
+}
diff --git 
a/cluster/src/main/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializer.scala
 
b/cluster/src/main/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializer.scala
index 48c0d4a83d..557f9a274b 100644
--- 
a/cluster/src/main/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializer.scala
+++ 
b/cluster/src/main/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializer.scala
@@ -314,7 +314,7 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
   private def deserializeInitJoin(bytes: Array[Byte]): 
InternalClusterAction.InitJoin = {
     val m = cm.InitJoin.parseFrom(bytes)
     if (m.hasCurrentConfig)
-      
InternalClusterAction.InitJoin(ConfigFactory.parseString(m.getCurrentConfig))
+      
InternalClusterAction.InitJoin(WireConfig.parseString(m.getCurrentConfig))
     else
       InternalClusterAction.InitJoin(ConfigFactory.empty)
   }
@@ -325,7 +325,7 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
       val configCheck =
         i.getConfigCheck.getType match {
           case cm.ConfigCheck.Type.CompatibleConfig =>
-            
CompatibleConfig(ConfigFactory.parseString(i.getConfigCheck.getClusterConfig))
+            
CompatibleConfig(WireConfig.parseString(i.getConfigCheck.getClusterConfig))
           case cm.ConfigCheck.Type.IncompatibleConfig => IncompatibleConfig
           case cm.ConfigCheck.Type.UncheckedConfig    => UncheckedConfig
         }
diff --git 
a/cluster/src/test/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializerSpec.scala
 
b/cluster/src/test/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializerSpec.scala
index 7615de9585..6bb4b07707 100644
--- 
a/cluster/src/test/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializerSpec.scala
+++ 
b/cluster/src/test/scala/org/apache/pekko/cluster/protobuf/ClusterMessageSerializerSpec.scala
@@ -13,6 +13,9 @@
 
 package org.apache.pekko.cluster.protobuf
 
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+
 import collection.immutable.SortedSet
 
 import scala.annotation.nowarn
@@ -22,6 +25,7 @@ import org.apache.pekko
 import pekko.actor.{ Address, ExtendedActorSystem }
 import pekko.cluster._
 import pekko.cluster.InternalClusterAction.CompatibleConfig
+import pekko.cluster.protobuf.msg.{ ClusterMessages => cm }
 import pekko.cluster.routing.{ ClusterRouterPool, ClusterRouterPoolSettings }
 import pekko.routing.RoundRobinPool
 import pekko.testkit.PekkoSpec
@@ -183,6 +187,37 @@ class ClusterMessageSerializerSpec extends 
PekkoSpec("pekko.actor.provider = clu
       env.gossip.members.tail.head.roles should be(Set("r1", 
ClusterSettings.DcRolePrefix + "foo"))
     }
 
+    "not resolve includes in the config of a join message" in {
+      // The joining node renders its config with ConfigRenderOptions.concise, 
which is JSON and
+      // cannot carry an include, so nothing legitimate is lost by refusing to 
resolve one. An
+      // include that did resolve would read a local file or issue an outbound 
request while
+      // deserializing a message from a node that has not joined yet.
+      val secretFile = Files.createTempFile("cluster-message-serializer-spec", 
".conf")
+      try {
+        Files.write(secretFile, """secret = 
"leaked"""".getBytes(StandardCharsets.UTF_8))
+        val hocon = s"""include file("${secretFile.toAbsolutePath}")
+          pekko.cluster.roles = []"""
+
+        val initJoin = serializer
+          
.fromBinary(cm.InitJoin.newBuilder().setCurrentConfig(hocon).build().toByteArray,
 "IJ")
+          .asInstanceOf[InternalClusterAction.InitJoin]
+        initJoin.configOfJoiningNode.hasPath("secret") should ===(false)
+
+        val ackBytes = cm.InitJoinAck
+          .newBuilder()
+          .setAddress(serializer.addressToProto(Address("pekko", "system", 
"some.host.org", 4711)))
+          .setConfigCheck(
+            cm.ConfigCheck
+              .newBuilder()
+              .setType(cm.ConfigCheck.Type.CompatibleConfig)
+              .setClusterConfig(hocon))
+          .build()
+          .toByteArray
+        val ack = serializer.fromBinary(ackBytes, 
"IJA").asInstanceOf[InternalClusterAction.InitJoinAck]
+        
ack.configCheck.asInstanceOf[CompatibleConfig].clusterConfig.hasPath("secret") 
should ===(false)
+      } finally Files.deleteIfExists(secretFile)
+    }
+
     "add a default data center role to internal join action if none is 
present" in {
       val join = roundtrip(InternalClusterAction.Join(a1.uniqueAddress, Set(), 
Version.Zero))
       join.roles should be(Set(ClusterSettings.DcRolePrefix + "default"))
diff --git 
a/remote/src/main/scala/org/apache/pekko/remote/serialization/MiscMessageSerializer.scala
 
b/remote/src/main/scala/org/apache/pekko/remote/serialization/MiscMessageSerializer.scala
index 70c6c92591..ad86f34b34 100644
--- 
a/remote/src/main/scala/org/apache/pekko/remote/serialization/MiscMessageSerializer.scala
+++ 
b/remote/src/main/scala/org/apache/pekko/remote/serialization/MiscMessageSerializer.scala
@@ -29,7 +29,13 @@ import pekko.remote._
 import pekko.remote.WireFormats.AddressData
 import pekko.remote.routing.RemoteRouterConfig
 import pekko.routing._
-import pekko.serialization.{ BaseSerializer, Serialization, 
SerializationExtension, SerializerWithStringManifest }
+import pekko.serialization.{
+  BaseSerializer,
+  Serialization,
+  SerializationExtension,
+  SerializerWithStringManifest,
+  WireConfig
+}
 import pekko.util.ccompat.JavaConverters._
 
 class MiscMessageSerializer(val system: ExtendedActorSystem) extends 
SerializerWithStringManifest with BaseSerializer {
@@ -544,7 +550,7 @@ class MiscMessageSerializer(val system: 
ExtendedActorSystem) extends SerializerW
 
   private def deserializeConfig(bytes: Array[Byte]): Config = {
     if (bytes.isEmpty) EmptyConfig
-    else ConfigFactory.parseString(new String(bytes, StandardCharsets.UTF_8))
+    else WireConfig.parseString(new String(bytes, StandardCharsets.UTF_8))
   }
 
   private def deserializeFromConfig(bytes: Array[Byte]): FromConfig =
diff --git 
a/remote/src/test/scala/org/apache/pekko/remote/serialization/MiscMessageSerializerSpec.scala
 
b/remote/src/test/scala/org/apache/pekko/remote/serialization/MiscMessageSerializerSpec.scala
index 570e3f8d4c..78b808a5e4 100644
--- 
a/remote/src/test/scala/org/apache/pekko/remote/serialization/MiscMessageSerializerSpec.scala
+++ 
b/remote/src/test/scala/org/apache/pekko/remote/serialization/MiscMessageSerializerSpec.scala
@@ -14,6 +14,8 @@
 package org.apache.pekko.remote.serialization
 
 import java.io.NotSerializableException
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
 import java.util.Optional
 import java.util.concurrent.TimeoutException
 
@@ -21,7 +23,7 @@ import scala.annotation.nowarn
 import scala.concurrent.duration._
 import scala.util.control.NoStackTrace
 
-import com.typesafe.config.ConfigFactory
+import com.typesafe.config.{ Config, ConfigFactory }
 import org.apache.pekko
 import pekko.{ Done, NotUsed }
 import pekko.actor._
@@ -157,6 +159,23 @@ class MiscMessageSerializerSpec extends 
PekkoSpec(MiscMessageSerializerSpec.test
         }
     }
 
+    "not resolve includes in a serialized Config" in {
+      // Config is written with ConfigRenderOptions.concise, which is JSON and 
cannot carry an
+      // include, so refusing to resolve one loses nothing. An include that 
did resolve would
+      // read a local file or issue an outbound request while deserializing a 
peer's message.
+      val secretFile = Files.createTempFile("misc-message-serializer-spec", 
".conf")
+      try {
+        Files.write(secretFile, """secret = 
"leaked"""".getBytes(StandardCharsets.UTF_8))
+        val serializer = new 
MiscMessageSerializer(system.asInstanceOf[ExtendedActorSystem])
+        val hocon = s"""include file("${secretFile.toAbsolutePath}")
+          a = 1"""
+
+        val config = 
serializer.fromBinary(hocon.getBytes(StandardCharsets.UTF_8), 
"CF").asInstanceOf[Config]
+        config.hasPath("secret") should ===(false)
+        config.getInt("a") should ===(1)
+      } finally Files.deleteIfExists(secretFile)
+    }
+
     "reject invalid manifest" in {
       intercept[IllegalArgumentException] {
         val serializer = new 
MiscMessageSerializer(system.asInstanceOf[ExtendedActorSystem])


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

Reply via email to