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

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


The following commit(s) were added to refs/heads/main by this push:
     new ff10ec2cca fix: reject messages whose parallel repeated fields 
disagree in length (#3507)
ff10ec2cca is described below

commit ff10ec2ccac43383536de15dd59f49afc3ff34ab
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Sep 7 13:09:46 2026 +0100

    fix: reject messages whose parallel repeated fields disagree in length 
(#3507)
    
    Motivation:
    Three places read repeated protobuf fields that are written in lockstep but
    read as if their lengths were guaranteed to agree.
    
    DaemonMsgCreateSerializer drives its loop over the constructor arguments by
    getSerializerIdsCount and indexes args, manifests and hasManifest with it,
    so a message where those disagree raises IndexOutOfBoundsException. The
    pre-2.4 branch zips args with manifests, which silently drops the tail of
    the longer one.
    
    ArteryMessageSerializer zips the keys and values of a compression table
    advertisement, so a mismatch silently builds a table the sender did not
    advertise, which is then acknowledged back to the sender as accepted. It
    also narrows the advertised table version, and the ack's version, from int
    to byte with byteValue, so versions 256 apart are indistinguishable.
    
    Modification:
    Check the lengths agree before indexing, and check the table version fits
    in a byte before narrowing it. Report either as NotSerializableException.
    Both are conditions no toBinary produces.
    
    Result:
    A malformed message is reported as a serialization failure rather than
    raising IndexOutOfBoundsException or being silently accepted as something
    other than what it said.
---
 .../serialization/ArteryMessageSerializer.scala    | 25 ++++++++++-
 .../serialization/DaemonMsgCreateSerializer.scala  | 25 +++++++++++
 .../ArteryMessageSerializerSpec.scala              | 52 ++++++++++++++++++++++
 .../DaemonMsgCreateSerializerAllowListSpec.scala   | 41 +++++++++++++++++
 4 files changed, 141 insertions(+), 2 deletions(-)

diff --git 
a/remote/src/main/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializer.scala
 
b/remote/src/main/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializer.scala
index b487f1f212..5c22206ab0 100644
--- 
a/remote/src/main/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializer.scala
+++ 
b/remote/src/main/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializer.scala
@@ -213,15 +213,36 @@ private[pekko] final class ArteryMessageSerializer(val 
system: ExtendedActorSyst
         s"Compression table advertisement carries [${protoAdv.getKeysCount}] 
entries, more than " +
         s"the configured maximum of [$maxEntries]")
 
+    // the two lists are parallel; `zip` on its own would drop the tail of the 
longer one and
+    // build a table the sender did not advertise, which is then acknowledged 
as accepted
+    if (protoAdv.getKeysCount != protoAdv.getValuesCount)
+      throw new NotSerializableException(
+        s"Compression table advertisement carries [${protoAdv.getKeysCount}] 
keys and " +
+        s"[${protoAdv.getValuesCount}] values, which must match")
+
     val kvs =
       protoAdv.getKeysList.asScala
         .map(keyDeserializer)
         .zip(protoAdv.getValuesList.asScala.asInstanceOf[Iterable[Int]] /* to 
avoid having to call toInt explicitly */ )
 
-    val table = CompressionTable[T](protoAdv.getOriginUid, 
protoAdv.getTableVersion.byteValue, kvs.toMap)
+    val table =
+      CompressionTable[T](protoAdv.getOriginUid, 
tableVersion(protoAdv.getTableVersion), kvs.toMap)
     create(deserializeUniqueAddress(protoAdv.getFrom), table)
   }
 
+  /**
+   * A compression table version is a `Byte` on both sides, so a value that 
does not survive the
+   * narrowing is not one any peer advertised. Narrowing it silently would 
make versions 256
+   * apart indistinguishable, and the version is echoed back to the sender in 
an ack.
+   */
+  private def tableVersion(version: Int): Byte = {
+    if (version < Byte.MinValue || version > Byte.MaxValue)
+      throw new NotSerializableException(
+        s"Compression table version [$version] is outside the range " +
+        s"[${Byte.MinValue}, ${Byte.MaxValue}] that a table version can hold")
+    version.toByte
+  }
+
   def serializeCompressionTableAdvertisementAck(from: UniqueAddress, version: 
Int): MessageLite =
     ArteryControlFormats.CompressionTableAdvertisementAck.newBuilder
       .setFrom(serializeUniqueAddress(from))
@@ -232,7 +253,7 @@ private[pekko] final class ArteryMessageSerializer(val 
system: ExtendedActorSyst
       bytes: Array[Byte],
       create: (UniqueAddress, Byte) => AnyRef): AnyRef = {
     val msg = 
ArteryControlFormats.CompressionTableAdvertisementAck.parseFrom(bytes)
-    create(deserializeUniqueAddress(msg.getFrom), msg.getVersion.toByte)
+    create(deserializeUniqueAddress(msg.getFrom), tableVersion(msg.getVersion))
   }
 
   def serializeSystemMessageEnvelope(
diff --git 
a/remote/src/main/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializer.scala
 
b/remote/src/main/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializer.scala
index d956d110cf..33394ef5a9 100644
--- 
a/remote/src/main/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializer.scala
+++ 
b/remote/src/main/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializer.scala
@@ -13,6 +13,8 @@
 
 package org.apache.pekko.remote.serialization
 
+import java.io.NotSerializableException
+
 import scala.collection.immutable
 import scala.jdk.CollectionConverters._
 
@@ -189,6 +191,14 @@ private[pekko] final class DaemonMsgCreateSerializer(val 
system: ExtendedActorSy
       val args: Vector[AnyRef] =
         // message from a newer node always contains serializer ids and 
possibly a string manifest for each position
         if (protoProps.getSerializerIdsCount > 0) {
+          // `toBinary` writes args, manifests, serializer ids and hasManifest 
one entry at a time,
+          // so the four are parallel. Indexing them by the serializer id 
count alone would raise
+          // IndexOutOfBoundsException on a message where they are not.
+          requireSameLength(
+            protoProps.getSerializerIdsCount,
+            "args" -> protoProps.getArgsCount,
+            "manifests" -> protoProps.getManifestsCount,
+            "hasManifest" -> protoProps.getHasManifestCount)
           for {
             idx <- (0 until protoProps.getSerializerIdsCount).toVector
           } yield {
@@ -202,6 +212,7 @@ private[pekko] final class DaemonMsgCreateSerializer(val 
system: ExtendedActorSy
         } else {
           // message from an older node, which only provides data and class 
name
           // and never any serializer ids
+          requireSameLength(protoProps.getArgsCount, "manifests" -> 
protoProps.getManifestsCount)
           proto.getProps.getArgsList.asScala
             .zip(proto.getProps.getManifestsList.asScala)
             .iterator
@@ -218,6 +229,20 @@ private[pekko] final class DaemonMsgCreateSerializer(val 
system: ExtendedActorSy
       supervisor = deserializeActorRef(system, proto.getSupervisor))
   }
 
+  /**
+   * The repeated fields of `PropsData` are parallel arrays; a message whose 
lengths disagree is
+   * one no `toBinary` produced. Reject it as a serialization failure rather 
than indexing past
+   * the end of the shorter one.
+   */
+  private def requireSameLength(expected: Int, counts: (String, Int)*): Unit =
+    counts.foreach {
+      case (name, count) =>
+        if (count != expected)
+          throw new NotSerializableException(
+            s"DaemonMsgCreate has [$expected] constructor arguments but 
[$count] $name; " +
+            "the repeated fields must all be the same length")
+    }
+
   private def checkAllowedActorClass(actorClass: Class[?]): Unit =
     if (!allowList.isAllowed(actorClass)) {
       val ex = new NotAllowedClassRemoteDeploymentAttemptException(actorClass, 
allowList.allowedClassNames)
diff --git 
a/remote/src/test/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializerSpec.scala
 
b/remote/src/test/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializerSpec.scala
index 8bced341d5..901a2ac73c 100644
--- 
a/remote/src/test/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializerSpec.scala
+++ 
b/remote/src/test/scala/org/apache/pekko/remote/serialization/ArteryMessageSerializerSpec.scala
@@ -98,6 +98,58 @@ class ArteryMessageSerializerSpec extends PekkoSpec {
       }.getMessage should include(s"more than the configured maximum of 
[$max]")
     }
 
+    "reject a compression table advertisement whose keys and values disagree 
in length" in {
+      val serializer = new 
ArteryMessageSerializer(system.asInstanceOf[ExtendedActorSystem])
+      val bytes = ArteryControlFormats.CompressionTableAdvertisement.newBuilder
+        .setFrom(serializer.serializeUniqueAddress(uniqueAddress()))
+        .setOriginUid(17L)
+        .setTableVersion(1)
+        .addKeys("a")
+        .addKeys("b")
+        .addValues(0)
+        .build()
+        .toByteArray
+
+      intercept[NotSerializableException] {
+        serializer.fromBinary(bytes, "h") // 
ClassManifestCompressionAdvertisement
+      }.getMessage should include("must match")
+    }
+
+    "reject a compression table version that does not fit in a byte" in {
+      val serializer = new 
ArteryMessageSerializer(system.asInstanceOf[ExtendedActorSystem])
+
+      // the version is a Byte on both sides, so 128 is not a version any peer 
advertised;
+      // narrowing it silently would make it indistinguishable from -128
+      val advertisement = 
ArteryControlFormats.CompressionTableAdvertisement.newBuilder
+        .setFrom(serializer.serializeUniqueAddress(uniqueAddress()))
+        .setOriginUid(17L)
+        .setTableVersion(128)
+        .build()
+        .toByteArray
+      intercept[NotSerializableException] {
+        serializer.fromBinary(advertisement, "h")
+      }.getMessage should include("outside the range")
+
+      val ack = 
ArteryControlFormats.CompressionTableAdvertisementAck.newBuilder
+        .setFrom(serializer.serializeUniqueAddress(uniqueAddress()))
+        .setVersion(128)
+        .build()
+        .toByteArray
+      intercept[NotSerializableException] {
+        serializer.fromBinary(ack, "i") // 
ClassManifestCompressionAdvertisementAck
+      }.getMessage should include("outside the range")
+    }
+
+    "accept the whole byte range of compression table versions" in {
+      val serializer = new 
ArteryMessageSerializer(system.asInstanceOf[ExtendedActorSystem])
+      Seq[Byte](Byte.MinValue, -1, 0, 1, Byte.MaxValue).foreach { version =>
+        withClue(s"version $version: ") {
+          val msg = ClassManifestCompressionAdvertisementAck(uniqueAddress(), 
version)
+          serializer.fromBinary(serializer.toBinary(msg), 
serializer.manifest(msg)) should ===(msg)
+        }
+      }
+    }
+
     "reject invalid manifest" in {
       intercept[IllegalArgumentException] {
         val serializer = new 
ArteryMessageSerializer(system.asInstanceOf[ExtendedActorSystem])
diff --git 
a/remote/src/test/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializerAllowListSpec.scala
 
b/remote/src/test/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializerAllowListSpec.scala
index 60a0f6c91d..a650770362 100644
--- 
a/remote/src/test/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializerAllowListSpec.scala
+++ 
b/remote/src/test/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializerAllowListSpec.scala
@@ -17,6 +17,7 @@
 
 package org.apache.pekko.remote.serialization
 
+import java.io.NotSerializableException
 import java.nio.charset.StandardCharsets.UTF_8
 import java.util.concurrent.atomic.AtomicInteger
 
@@ -27,6 +28,7 @@ import pekko.actor.Actor
 import pekko.actor.Deploy
 import pekko.actor.Props
 import pekko.remote.DaemonMsgCreate
+import pekko.remote.WireFormats
 import pekko.remote.NotAllowedClassRemoteDeploymentAttemptException
 import pekko.serialization.SerializationExtension
 import pekko.serialization.SerializerWithStringManifest
@@ -88,6 +90,45 @@ class DaemonMsgCreateSerializerAllowListSpec
 
   "DaemonMsgCreateSerializer with the remote deployment allow list enabled" 
must {
 
+    "reject props whose repeated fields disagree in length" in {
+      val msg = daemonMsgCreate(classOf[AllowedActor])
+      val serializer = ser.findSerializerFor(msg)
+      val proto = 
WireFormats.DaemonMsgCreateData.parseFrom(serializer.toBinary(msg))
+      proto.getProps.getSerializerIdsCount should ===(1)
+
+      // one more serializer id than there are args, manifests and hasManifest 
entries; the loop
+      // is driven by the serializer id count, so this used to index past the 
end of the others
+      val tampered = proto.toBuilder
+        .setProps(proto.getProps.toBuilder.addSerializerIds(0))
+        .build()
+        .toByteArray
+
+      intercept[NotSerializableException] {
+        serializer.fromBinary(tampered, None)
+      }.getMessage should include("same length")
+    }
+
+    "reject old format props whose args and manifests disagree in length" in {
+      val msg = daemonMsgCreate(classOf[AllowedActor])
+      val serializer = ser.findSerializerFor(msg)
+      val proto = 
WireFormats.DaemonMsgCreateData.parseFrom(serializer.toBinary(msg))
+
+      // no serializer ids selects the pre-2.4 branch, where args and 
manifests were zipped and a
+      // longer manifest list was silently dropped
+      val tampered = proto.toBuilder
+        .setProps(
+          proto.getProps.toBuilder
+            .clearSerializerIds()
+            .clearHasManifest()
+            .addManifests(classOf[String].getName))
+        .build()
+        .toByteArray
+
+      intercept[NotSerializableException] {
+        serializer.fromBinary(tampered, None)
+      }.getMessage should include("same length")
+    }
+
     "deserialize a DaemonMsgCreate for an allow-listed class" in {
       val bytes = ser.serialize(daemonMsgCreate(classOf[AllowedActor])).get
       val got = ser.deserialize(bytes, 
classOf[DaemonMsgCreate]).get.asInstanceOf[DaemonMsgCreate]


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

Reply via email to