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 67c3b557e5 fix: resolve, check and parse manifests more carefully in 
the Jackson serializers (#3503)
67c3b557e5 is described below

commit 67c3b557e538416fe55b9cbe3745817dbb4b6413
Author: PJ Fanning <[email protected]>
AuthorDate: Thu Sep 3 09:16:28 2026 +0100

    fix: resolve, check and parse manifests more carefully in the Jackson 
serializers (#3503)
    
    Motivation:
    Four issues in JacksonSerializer, all reachable from a wire manifest:
    
    - The case object branch called getObjectFor before checkAllowedClass.
      getObjectFor reads the MODULE$ field, which initializes the class, so a
      manifest naming a class the allow list would reject ran that class's
      initializer on the way to being rejected.
    - LZ4Meta.get checked for 4 remaining bytes and then read 8, so a 4 to 7
      byte payload beginning with the LZ4 magic raised BufferUnderflowException.
    - parseManifest called toInt on whatever followed the last '#', so a
      non-numeric version raised NumberFormatException.
    - isInAllowList evaluated isBoundToJacksonSerializer first, which calls
      serializerFor and raises (filling in a stack trace) for a class that is
      allowed only by prefix. checkAllowedClass runs on every fromBinary.
    
    Modification:
    Resolve the case object's class with getClassFor, which does not initialize,
    run checkAllowedClass on it, and only then read the module field. Require 8
    remaining bytes for an LZ4 header. Parse the manifest version with
    toIntOption and report a bad one as NotSerializableException. Test the
    prefix before the binding, which cannot throw. Applied to both
    serialization-jackson and serialization-jackson3.
    
    Result:
    A rejected manifest no longer initializes the class it names, two malformed
    manifests are reported as serialization failures rather than as unrelated
    runtime exceptions, and the allow list check no longer builds an exception
    per message on the prefix path. No change for manifests that were accepted
    before.
---
 .../serialization/jackson/JacksonSerializer.scala  | 30 +++++++++++---
 .../jackson/CaseObjectInitializationProbe.scala    | 35 ++++++++++++++++
 .../jackson/JacksonSerializerSpec.scala            | 48 ++++++++++++++++++++++
 .../serialization/jackson3/JacksonSerializer.scala | 30 +++++++++++---
 .../jackson3/CaseObjectInitializationProbe.scala   | 35 ++++++++++++++++
 .../jackson3/JacksonSerializerSpec.scala           | 48 ++++++++++++++++++++++
 6 files changed, 216 insertions(+), 10 deletions(-)

diff --git 
a/serialization-jackson/src/main/scala/org/apache/pekko/serialization/jackson/JacksonSerializer.scala
 
b/serialization-jackson/src/main/scala/org/apache/pekko/serialization/jackson/JacksonSerializer.scala
index 82fb9cd3f8..01fa6d016f 100644
--- 
a/serialization-jackson/src/main/scala/org/apache/pekko/serialization/jackson/JacksonSerializer.scala
+++ 
b/serialization-jackson/src/main/scala/org/apache/pekko/serialization/jackson/JacksonSerializer.scala
@@ -119,7 +119,8 @@ import pekko.util.OptionVal
     }
 
     def get(buffer: ByteBuffer): OptionVal[LZ4Meta] = {
-      if (buffer.remaining() < 4) {
+      // the header is the magic plus the declared length, so 8 bytes are read 
below
+      if (buffer.remaining() < 8) {
         OptionVal.None
       } else if (buffer.getInt() != LZ4_MAGIC) {
         OptionVal.None
@@ -347,14 +348,23 @@ import pekko.util.OptionVal
       checkAllowedClassName(className)
 
     if (isCaseObject(className)) {
+      // `getObjectFor` reads the MODULE$ field, which initializes the class. 
Resolve the class
+      // with `getClassFor` first, which does not initialize, and run the 
allow list check on it,
+      // so a manifest naming a class this serializer would reject cannot run 
that class's
+      // initializer on the way to being rejected.
+      val clazz = system.dynamicAccess.getClassFor[AnyRef](className) match {
+        case Success(c) => c
+        case Failure(_) =>
+          throw new NotSerializableException(
+            s"Cannot find manifest case object [$className] for serializer 
[${getClass.getName}].")
+      }
+      checkAllowedClass(clazz)
       val result = system.dynamicAccess.getObjectFor[AnyRef](className) match {
         case Success(obj) => obj
         case Failure(_)   =>
           throw new NotSerializableException(
             s"Cannot find manifest case object [$className] for serializer 
[${getClass.getName}].")
       }
-      val clazz = result.getClass
-      checkAllowedClass(clazz)
       // no migrations for case objects, since no json tree
       logFromBinaryDuration(bytes, bytes, startTime, clazz)
       result
@@ -460,7 +470,10 @@ import pekko.util.OptionVal
    * and still bind with the same class (interface).
    */
   private def isInAllowList(clazz: Class[?]): Boolean = {
-    isBoundToJacksonSerializer(clazz) || hasAllowedClassPrefix(clazz.getName)
+    // The prefix check comes first because it cannot throw: 
`isBoundToJacksonSerializer` calls
+    // `serializerFor`, which raises (and fills in the stack trace of) a 
NotSerializableException
+    // for an unbound class, and this runs on every `fromBinary`.
+    hasAllowedClassPrefix(clazz.getName) || isBoundToJacksonSerializer(clazz)
   }
 
   private def isBoundToJacksonSerializer(clazz: Class[?]): Boolean = {
@@ -509,7 +522,14 @@ import pekko.util.OptionVal
 
   private def parseManifest(manifest: String) = {
     val i = manifest.lastIndexOf('#')
-    val fromVersion = if (i == -1) 1 else manifest.substring(i + 1).toInt
+    val fromVersion =
+      if (i == -1) 1
+      else
+        manifest
+          .substring(i + 1)
+          .toIntOption
+          .getOrElse(throw new NotSerializableException(
+            s"Manifest [$manifest] for serializer [${getClass.getName}] does 
not end with a numeric version."))
     val manifestClassName = if (i == -1) manifest else manifest.substring(0, i)
     (fromVersion, manifestClassName)
   }
diff --git 
a/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/CaseObjectInitializationProbe.scala
 
b/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/CaseObjectInitializationProbe.scala
new file mode 100644
index 0000000000..3da2ef5ec4
--- /dev/null
+++ 
b/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/CaseObjectInitializationProbe.scala
@@ -0,0 +1,35 @@
+/*
+ * 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.jackson
+
+/**
+ * Records whether [[NotAllowedCaseObject]] has been initialized. A test 
asserts this stays
+ * false while deserializing a manifest that names it, so refer to that object 
by its class
+ * name as a string and never in code, which would initialize it.
+ */
+object CaseObjectInitializationProbe {
+  @volatile var initialized: Boolean = false
+}
+
+/**
+ * Neither bound to a Jackson serializer nor covered by 
`allowed-class-prefix`, so a manifest
+ * naming it has to be rejected by the allow list.
+ */
+object NotAllowedCaseObject {
+  CaseObjectInitializationProbe.initialized = true
+}
diff --git 
a/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/JacksonSerializerSpec.scala
 
b/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/JacksonSerializerSpec.scala
index b2b6c2eb2c..1265562c95 100644
--- 
a/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/JacksonSerializerSpec.scala
+++ 
b/serialization-jackson/src/test/scala/org/apache/pekko/serialization/jackson/JacksonSerializerSpec.scala
@@ -14,6 +14,7 @@
 package org.apache.pekko.serialization.jackson
 
 import java.lang
+import java.io.NotSerializableException
 import java.nio.charset.StandardCharsets
 import java.time.Duration
 import java.time.Instant
@@ -856,6 +857,9 @@ abstract class JacksonSerializerSpec(serializerName: String)
     serialization(sys).deserialize(blob, serializerId, manifest).get
   }
 
+  // referenced by name only: mentioning the object in code would initialize it
+  val NotAllowedCaseObjectName = 
"org.apache.pekko.serialization.jackson.NotAllowedCaseObject$"
+
   def serializerFor(obj: AnyRef, sys: ActorSystem = system): JacksonSerializer 
=
     serialization(sys).findSerializerFor(obj) match {
       case serializer: JacksonSerializer => serializer
@@ -1311,6 +1315,50 @@ abstract class JacksonSerializerSpec(serializerName: 
String)
       }
     }
 
+    "not allow deserialization of a case object that is not in 
serialization-bindings" in {
+      withTransportInformation() { () =>
+        val msg = SimpleCommand("ok")
+        val serializer = serializerFor(msg)
+        val blob = serializer.toBinary(msg)
+        intercept[IllegalArgumentException] {
+          // maliciously changing manifest to a case object
+          serializer.fromBinary(blob, NotAllowedCaseObjectName)
+        }.getMessage.toLowerCase should include("allowed-class-prefix")
+      }
+    }
+
+    "not initialize a case object class it goes on to reject" in {
+      withTransportInformation() { () =>
+        val msg = SimpleCommand("ok")
+        val serializer = serializerFor(msg)
+        val blob = serializer.toBinary(msg)
+        CaseObjectInitializationProbe.initialized should ===(false)
+        intercept[IllegalArgumentException] {
+          serializer.fromBinary(blob, NotAllowedCaseObjectName)
+        }
+        // reading MODULE$ would have run the object's body
+        CaseObjectInitializationProbe.initialized should ===(false)
+      }
+    }
+
+    "reject a manifest whose version is not a number" in {
+      withTransportInformation() { () =>
+        val msg = SimpleCommand("ok")
+        val serializer = serializerFor(msg)
+        val blob = serializer.toBinary(msg)
+        intercept[NotSerializableException] {
+          serializer.fromBinary(blob, classOf[SimpleCommand].getName + 
"#not-a-number")
+        }.getMessage should include("numeric version")
+      }
+    }
+
+    "not underflow on a payload that is only as long as the LZ4 magic" in {
+      // the LZ4 header is the magic plus a 4 byte length, so 4 bytes cannot 
be an LZ4 payload
+      JacksonSerializer.isLZ4(Array(0x87, 0xD9, 0x6D, 0xF6).map(_.toByte)) 
should ===(false)
+      
JacksonSerializer.isLZ4(JacksonSerializer.LZ4Meta(Array.emptyByteArray).prependTo(Array.emptyByteArray))
 should
+      ===(true)
+    }
+
     "not allow serialization-bindings of open-ended types" in {
       JacksonSerializer.disallowedSerializationBindings.foreach { clazz =>
         val className = clazz.getName
diff --git 
a/serialization-jackson3/src/main/scala/org/apache/pekko/serialization/jackson3/JacksonSerializer.scala
 
b/serialization-jackson3/src/main/scala/org/apache/pekko/serialization/jackson3/JacksonSerializer.scala
index 6a9db1da2c..9c6181d358 100644
--- 
a/serialization-jackson3/src/main/scala/org/apache/pekko/serialization/jackson3/JacksonSerializer.scala
+++ 
b/serialization-jackson3/src/main/scala/org/apache/pekko/serialization/jackson3/JacksonSerializer.scala
@@ -120,7 +120,8 @@ import pekko.util.OptionVal
     }
 
     def get(buffer: ByteBuffer): OptionVal[LZ4Meta] = {
-      if (buffer.remaining() < 4) {
+      // the header is the magic plus the declared length, so 8 bytes are read 
below
+      if (buffer.remaining() < 8) {
         OptionVal.None
       } else if (buffer.getInt() != LZ4_MAGIC) {
         OptionVal.None
@@ -348,14 +349,23 @@ import pekko.util.OptionVal
       checkAllowedClassName(className)
 
     if (isCaseObject(className)) {
+      // `getObjectFor` reads the MODULE$ field, which initializes the class. 
Resolve the class
+      // with `getClassFor` first, which does not initialize, and run the 
allow list check on it,
+      // so a manifest naming a class this serializer would reject cannot run 
that class's
+      // initializer on the way to being rejected.
+      val clazz = system.dynamicAccess.getClassFor[AnyRef](className) match {
+        case Success(c) => c
+        case Failure(_) =>
+          throw new NotSerializableException(
+            s"Cannot find manifest case object [$className] for serializer 
[${getClass.getName}].")
+      }
+      checkAllowedClass(clazz)
       val result = system.dynamicAccess.getObjectFor[AnyRef](className) match {
         case Success(obj) => obj
         case Failure(_)   =>
           throw new NotSerializableException(
             s"Cannot find manifest case object [$className] for serializer 
[${getClass.getName}].")
       }
-      val clazz = result.getClass
-      checkAllowedClass(clazz)
       // no migrations for case objects, since no json tree
       logFromBinaryDuration(bytes, bytes, startTime, clazz)
       result
@@ -461,7 +471,10 @@ import pekko.util.OptionVal
    * and still bind with the same class (interface).
    */
   private def isInAllowList(clazz: Class[?]): Boolean = {
-    isBoundToJacksonSerializer(clazz) || hasAllowedClassPrefix(clazz.getName)
+    // The prefix check comes first because it cannot throw: 
`isBoundToJacksonSerializer` calls
+    // `serializerFor`, which raises (and fills in the stack trace of) a 
NotSerializableException
+    // for an unbound class, and this runs on every `fromBinary`.
+    hasAllowedClassPrefix(clazz.getName) || isBoundToJacksonSerializer(clazz)
   }
 
   private def isBoundToJacksonSerializer(clazz: Class[?]): Boolean = {
@@ -510,7 +523,14 @@ import pekko.util.OptionVal
 
   private def parseManifest(manifest: String) = {
     val i = manifest.lastIndexOf('#')
-    val fromVersion = if (i == -1) 1 else manifest.substring(i + 1).toInt
+    val fromVersion =
+      if (i == -1) 1
+      else
+        manifest
+          .substring(i + 1)
+          .toIntOption
+          .getOrElse(throw new NotSerializableException(
+            s"Manifest [$manifest] for serializer [${getClass.getName}] does 
not end with a numeric version."))
     val manifestClassName = if (i == -1) manifest else manifest.substring(0, i)
     (fromVersion, manifestClassName)
   }
diff --git 
a/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/CaseObjectInitializationProbe.scala
 
b/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/CaseObjectInitializationProbe.scala
new file mode 100644
index 0000000000..3ef5ec25d4
--- /dev/null
+++ 
b/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/CaseObjectInitializationProbe.scala
@@ -0,0 +1,35 @@
+/*
+ * 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.jackson3
+
+/**
+ * Records whether [[NotAllowedCaseObject]] has been initialized. A test 
asserts this stays
+ * false while deserializing a manifest that names it, so refer to that object 
by its class
+ * name as a string and never in code, which would initialize it.
+ */
+object CaseObjectInitializationProbe {
+  @volatile var initialized: Boolean = false
+}
+
+/**
+ * Neither bound to a Jackson serializer nor covered by 
`allowed-class-prefix`, so a manifest
+ * naming it has to be rejected by the allow list.
+ */
+object NotAllowedCaseObject {
+  CaseObjectInitializationProbe.initialized = true
+}
diff --git 
a/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/JacksonSerializerSpec.scala
 
b/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/JacksonSerializerSpec.scala
index 8338f13c40..551e22426c 100644
--- 
a/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/JacksonSerializerSpec.scala
+++ 
b/serialization-jackson3/src/test/scala/org/apache/pekko/serialization/jackson3/JacksonSerializerSpec.scala
@@ -14,6 +14,7 @@
 package org.apache.pekko.serialization.jackson3
 
 import java.lang
+import java.io.NotSerializableException
 import java.nio.charset.StandardCharsets
 import java.time.{ Duration, Instant, LocalDateTime }
 import java.time.temporal.ChronoUnit
@@ -801,6 +802,9 @@ abstract class JacksonSerializerSpec(serializerName: String)
     serialization(sys).deserialize(blob, serializerId, manifest).get
   }
 
+  // referenced by name only: mentioning the object in code would initialize it
+  val NotAllowedCaseObjectName = 
"org.apache.pekko.serialization.jackson3.NotAllowedCaseObject$"
+
   def serializerFor(obj: AnyRef, sys: ActorSystem = system): JacksonSerializer 
=
     serialization(sys).findSerializerFor(obj) match {
       case serializer: JacksonSerializer => serializer
@@ -1256,6 +1260,50 @@ abstract class JacksonSerializerSpec(serializerName: 
String)
       }
     }
 
+    "not allow deserialization of a case object that is not in 
serialization-bindings" in {
+      withTransportInformation() { () =>
+        val msg = SimpleCommand("ok")
+        val serializer = serializerFor(msg)
+        val blob = serializer.toBinary(msg)
+        intercept[IllegalArgumentException] {
+          // maliciously changing manifest to a case object
+          serializer.fromBinary(blob, NotAllowedCaseObjectName)
+        }.getMessage.toLowerCase should include("allowed-class-prefix")
+      }
+    }
+
+    "not initialize a case object class it goes on to reject" in {
+      withTransportInformation() { () =>
+        val msg = SimpleCommand("ok")
+        val serializer = serializerFor(msg)
+        val blob = serializer.toBinary(msg)
+        CaseObjectInitializationProbe.initialized should ===(false)
+        intercept[IllegalArgumentException] {
+          serializer.fromBinary(blob, NotAllowedCaseObjectName)
+        }
+        // reading MODULE$ would have run the object's body
+        CaseObjectInitializationProbe.initialized should ===(false)
+      }
+    }
+
+    "reject a manifest whose version is not a number" in {
+      withTransportInformation() { () =>
+        val msg = SimpleCommand("ok")
+        val serializer = serializerFor(msg)
+        val blob = serializer.toBinary(msg)
+        intercept[NotSerializableException] {
+          serializer.fromBinary(blob, classOf[SimpleCommand].getName + 
"#not-a-number")
+        }.getMessage should include("numeric version")
+      }
+    }
+
+    "not underflow on a payload that is only as long as the LZ4 magic" in {
+      // the LZ4 header is the magic plus a 4 byte length, so 4 bytes cannot 
be an LZ4 payload
+      JacksonSerializer.isLZ4(Array(0x87, 0xD9, 0x6D, 0xF6).map(_.toByte)) 
should ===(false)
+      
JacksonSerializer.isLZ4(JacksonSerializer.LZ4Meta(Array.emptyByteArray).prependTo(Array.emptyByteArray))
 should
+      ===(true)
+    }
+
     "not allow serialization-bindings of open-ended types" in {
       JacksonSerializer.disallowedSerializationBindings.foreach { clazz =>
         val className = clazz.getName


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

Reply via email to