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 9a54d1294c fix: bounds check the lookup table indexes in gossip 
(#3508) (#3520)
9a54d1294c is described below

commit 9a54d1294c3ac7821bbed3c940769c3d5027d1ff
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 4 15:26:38 2026 +0100

    fix: bounds check the lookup table indexes in gossip (#3508) (#3520)
    
    * fix: bounds check the lookup table indexes in gossip (#3508)
    
    Motivation:
    Gossip interns addresses, roles, hashes and app versions into tables and
    refers to them by index. Every index in gossipFromProto came straight off
    the wire into Vector.apply with no range check, so a negative or out of
    range one raised IndexOutOfBoundsException instead of a serialization
    failure.
    
    For a GossipEnvelope this matters more than usual: gossipEnvelopeFromProto
    defers the parse into a thunk, so the throw happens inside ClusterCoreDaemon
    when the gossip is read rather than on a deserialization thread.
    
    Modification:
    Look the indexes up through a helper that range checks first and reports a
    NotSerializableException naming the index and the table size. Every index
    gossipToProto writes is in range, so nothing a peer legitimately sends is
    affected.
    
    Result:
    Gossip that refers to a table entry the sender did not include is reported
    as a serialization failure.
    
    * Add imports for StandardCharsets and Files
---
 .../protobuf/ClusterMessageSerializer.scala        | 34 +++++++---
 .../protobuf/ClusterMessageSerializerSpec.scala    | 75 ++++++++++++++++++++++
 2 files changed, 100 insertions(+), 9 deletions(-)

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 aa6332785f..4c74ae6a4c 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
@@ -13,7 +13,7 @@
 
 package org.apache.pekko.cluster.protobuf
 
-import java.io.ByteArrayOutputStream
+import java.io.{ ByteArrayOutputStream, NotSerializableException }
 import java.util.zip.GZIPOutputStream
 import scala.collection.immutable
 import scala.concurrent.duration.Deadline
@@ -390,6 +390,20 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
     case _       => throw new IllegalArgumentException(s"Unknown $unknown 
[$value] in cluster message")
   }
 
+  /**
+   * Gossip interns addresses, roles, hashes and app versions into tables and 
refers to them by
+   * index. Every index `gossipToProto` writes is in range, so one that is not 
came from a
+   * message no `toBinary` produced; resolve it as a serialization failure 
rather than letting
+   * `Vector.apply` raise IndexOutOfBoundsException. For a GossipEnvelope this 
matters more than
+   * usual, because the parse is deferred and runs on the cluster daemon 
rather than on a
+   * deserialization thread.
+   */
+  private def lookup[T](mapping: immutable.Seq[T], index: Int, what: String): 
T =
+    if (index < 0 || index >= mapping.size)
+      throw new NotSerializableException(
+        s"Cluster message refers to $what index [$index], but only 
[${mapping.size}] were sent")
+    else mapping(index)
+
   private def joinToProto(node: UniqueAddress, roles: Set[String], appVersion: 
Version): cm.Join =
     cm.Join
       .newBuilder()
@@ -549,10 +563,10 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
       val recordBuilder = new immutable.VectorBuilder[Reachability.Record]
       val versionsBuilder = Map.newBuilder[UniqueAddress, Long]
       for (o <- observerReachability) {
-        val observer = addressMapping(o.getAddressIndex)
+        val observer = lookup(addressMapping, o.getAddressIndex, "address")
         versionsBuilder += ((observer, o.getVersion))
         for (s <- o.getSubjectReachabilityList.asScala) {
-          val subject = addressMapping(s.getAddressIndex)
+          val subject = lookup(addressMapping, s.getAddressIndex, "address")
           val record =
             Reachability.Record(observer, subject, 
reachabilityStatusFromInt(s.getStatus.getNumber), s.getVersion)
           recordBuilder += record
@@ -564,11 +578,13 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
 
     def memberFromProto(member: cm.Member) =
       new Member(
-        addressMapping(member.getAddressIndex),
+        lookup(addressMapping, member.getAddressIndex, "address"),
         member.getUpNumber,
         memberStatusFromInt(member.getStatus.getNumber),
         rolesFromProto(member.getRolesIndexesList.asScala.toSeq),
-        if (appVersionMapping.isEmpty) Version.Zero else 
appVersionMapping(member.getAppVersionIndex))
+        // an older node sends no app versions at all, which is not the same 
as an index it did not send
+        if (appVersionMapping.isEmpty) Version.Zero
+        else lookup(appVersionMapping, member.getAppVersionIndex, "app 
version"))
 
     def rolesFromProto(roleIndexes: Seq[Integer]): Set[String] = {
       var containsDc = false
@@ -576,7 +592,7 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
 
       for {
         roleIndex <- roleIndexes
-        role = roleMapping(roleIndex)
+        role = lookup(roleMapping, roleIndex, "role")
       } {
         if (role.startsWith(ClusterSettings.DcRolePrefix)) containsDc = true
         roles += role
@@ -587,14 +603,14 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
     }
 
     def tombstoneFromProto(tombstone: cm.Tombstone): (UniqueAddress, Long) =
-      (addressMapping(tombstone.getAddressIndex), tombstone.getTimestamp)
+      (lookup(addressMapping, tombstone.getAddressIndex, "address"), 
tombstone.getTimestamp)
 
     val members: immutable.SortedSet[Member] =
       
gossip.getMembersList.asScala.iterator.map(memberFromProto).to(immutable.SortedSet)
 
     val reachability = 
reachabilityFromProto(gossip.getOverview.getObserverReachabilityList.asScala)
     val seen: Set[UniqueAddress] =
-      
gossip.getOverview.getSeenList.asScala.iterator.map(addressMapping(_)).to(immutable.Set)
+      
gossip.getOverview.getSeenList.asScala.iterator.map(lookup(addressMapping, _, 
"address")).to(immutable.Set)
     val overview = GossipOverview(seen, reachability)
     val tombstones: Map[UniqueAddress, Long] = 
gossip.getTombstonesList.asScala.iterator.map(tombstoneFromProto).toMap
 
@@ -603,7 +619,7 @@ final class ClusterMessageSerializer(val system: 
ExtendedActorSystem)
 
   private def vectorClockFromProto(version: cm.VectorClock, hashMapping: 
immutable.Seq[String]) = {
     
VectorClock(scala.collection.immutable.TreeMap.from(version.getVersionsList.asScala.iterator.map(v
 =>
-      (VectorClock.Node.fromHash(hashMapping(v.getHashIndex)), 
v.getTimestamp))))
+      (VectorClock.Node.fromHash(lookup(hashMapping, v.getHashIndex, "hash")), 
v.getTimestamp))))
   }
 
   private def gossipEnvelopeFromProto(envelope: cm.GossipEnvelope): 
GossipEnvelope = {
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 6bb4b07707..7df867f874 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,7 @@
 
 package org.apache.pekko.cluster.protobuf
 
+import java.io.NotSerializableException
 import java.nio.charset.StandardCharsets
 import java.nio.file.Files
 
@@ -27,6 +28,7 @@ import pekko.cluster._
 import pekko.cluster.InternalClusterAction.CompatibleConfig
 import pekko.cluster.protobuf.msg.{ ClusterMessages => cm }
 import pekko.cluster.routing.{ ClusterRouterPool, ClusterRouterPoolSettings }
+import pekko.remote.ByteStringUtils
 import pekko.routing.RoundRobinPool
 import pekko.testkit.PekkoSpec
 import pekko.util.Version
@@ -181,6 +183,79 @@ class ClusterMessageSerializerSpec extends 
PekkoSpec("pekko.actor.provider = clu
         ClusterMessageSerializer.OldWelcomeManifest)
     }
 
+    "reject gossip that refers to a lookup table entry it did not send" in {
+      // Gossip interns addresses, roles, hashes and app versions and refers 
to them by index.
+      // Every index gossipToProto writes is in range, so an out of range one 
is a message no
+      // toBinary produced; it must not surface as IndexOutOfBoundsException.
+      val node1 = VectorClock.Node("node1")
+      val gossip = (Gossip(SortedSet(a1, b1)) :+ node1).seen(a1.uniqueAddress)
+      val welcome = InternalClusterAction.Welcome(a1.uniqueAddress, gossip)
+      val proto = 
cm.Welcome.parseFrom(serializer.decompress(serializer.toBinary(welcome)))
+
+      def rejects(tamper: cm.Gossip.Builder => Unit): String = {
+        val g = proto.getGossip.toBuilder
+        tamper(g)
+        val bytes = serializer.compress(proto.toBuilder.setGossip(g).build())
+        intercept[NotSerializableException](serializer.fromBinary(bytes, 
"W")).getMessage
+      }
+
+      // an address index one past the end of allAddresses
+      rejects(_.setMembers(0, 
proto.getGossip.getMembers(0).toBuilder.setAddressIndex(99))) should include(
+        "address index [99]")
+      // a negative index
+      rejects(_.setMembers(0, 
proto.getGossip.getMembers(0).toBuilder.setAddressIndex(-1))) should include(
+        "address index [-1]")
+      // a role index out of range
+      rejects(_.setMembers(0, 
proto.getGossip.getMembers(0).toBuilder.setRolesIndexes(0, 99))) should include(
+        "role index [99]")
+      // a seen entry pointing nowhere
+      rejects(_.setOverview(proto.getGossip.getOverview.toBuilder.setSeen(0, 
99))) should include("address index [99]")
+      // a vector clock hash index pointing nowhere
+      rejects(
+        _.setVersion(
+          proto.getGossip.getVersion.toBuilder
+            .setVersions(0, 
proto.getGossip.getVersion.getVersions(0).toBuilder.setHashIndex(99)))) should 
include(
+        "hash index [99]")
+    }
+
+    "reject a gossip status that refers to a hash it did not send" in {
+      val node1 = VectorClock.Node("node1")
+      val gossip = Gossip(SortedSet(a1)) :+ node1
+      val status = GossipStatus(a1.uniqueAddress, gossip.version, 
gossip.seenDigest)
+      val proto = cm.GossipStatus.parseFrom(serializer.toBinary(status))
+
+      val tampered = proto.toBuilder
+        .setVersion(proto.getVersion.toBuilder.setVersions(0,
+          proto.getVersion.getVersions(0).toBuilder.setHashIndex(7)))
+        .build()
+        .toByteArray
+
+      intercept[NotSerializableException] {
+        serializer.fromBinary(tampered, "GS")
+      }.getMessage should include("hash index [7]")
+    }
+
+    "reject a gossip envelope with a bad index when the gossip is read" in {
+      // GossipEnvelope defers the parse, so the failure surfaces from 
`gossip` rather than from
+      // fromBinary - on the cluster daemon's thread rather than a 
deserialization thread.
+      val gossip = Gossip(SortedSet(a1, b1))
+      val envelope = GossipEnvelope(a1.uniqueAddress, b1.uniqueAddress, gossip)
+      val proto = cm.GossipEnvelope.parseFrom(serializer.toBinary(envelope))
+      val inner = 
cm.Gossip.parseFrom(serializer.decompress(proto.getSerializedGossip.toByteArray))
+      val tamperedInner =
+        inner.toBuilder.setMembers(0, 
inner.getMembers(0).toBuilder.setAddressIndex(99)).build()
+
+      val bytes = proto.toBuilder
+        
.setSerializedGossip(ByteStringUtils.toProtoByteStringUnsafe(serializer.compress(tamperedInner)))
+        .build()
+        .toByteArray
+
+      val msg = serializer.fromBinary(bytes, "GE").asInstanceOf[GossipEnvelope]
+      intercept[NotSerializableException] {
+        msg.gossip
+      }.getMessage should include("address index [99]")
+    }
+
     "add a default data center role to gossip if none is present" in {
       val env = roundtrip(GossipEnvelope(a1.uniqueAddress, d1.uniqueAddress, 
Gossip(SortedSet(a1, d1))))
       env.gossip.members.head.roles should be(Set(ClusterSettings.DcRolePrefix 
+ "default"))


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

Reply via email to