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 5b645144ea fix: bound nesting depth when deserializing enclosed
payloads (#3500) (#3501)
5b645144ea is described below
commit 5b645144ea46230516085440cb639608a20dc1f1
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Sep 2 12:59:01 2026 +0100
fix: bound nesting depth when deserializing enclosed payloads (#3500)
(#3501)
* fix: bound nesting depth when deserializing enclosed payloads (#3500)
Motivation:
Several of the remoting wire formats enclose a serialized payload whose
enclosed
message is itself a serialized payload (for example Some, Optional,
Status.Failure,
StatusReply, a Throwable cause, or an ActorSelectionMessage). Each level is
parsed
separately, so neither a protobuf parser's own nesting limit nor the size
of the
message bounds how deep the chain can go relative to the stack: the
recursion tracks
the nesting rather than the number of bytes. A sufficiently deeply nested
message
fails with a StackOverflowError rather than a serialization error.
Modification:
Add a per-thread nesting-depth counter (NestedDeserialization). A level is
counted
where a serializer is actually invoked: Serialization.deserializeByteArray
counts
one, and in WrappedPayloadSupport.deserializePayload the two branches that
call a
serializer directly count one each, while the branch that delegates back to
Serialization leaves the counting to it. Nesting deeper than
pekko.actor.serialization-max-nesting-depth (default 32) is rejected with a
NotSerializableException. Ordinary nesting depths are unaffected.
Result:
An over-nested payload is rejected as an ordinary serialization error
instead of
exhausting the stack.
Tests:
- sbt "remote/testOnly
org.apache.pekko.remote.serialization.NestedPayloadDepthSpec" - 5 passed
- sbt "actor-tests/testOnly org.apache.pekko.serialization.SerializeSpec
org.apache.pekko.serialization.WireManifestClassLoadingSpec" - 21 passed
- sbt "actor/mimaReportBinaryIssues" "remote/mimaReportBinaryIssues" - no
issues
- scalafmt on the changed Scala sources
References:
None - robustness of nested payload deserialization
* fix: keep allow-java-serialization comment attached to its setting
* docs: note why the depth counter is a one-element Array[Int]
---
actor/src/main/resources/reference.conf | 7 ++
.../serialization/NestedDeserialization.scala | 65 +++++++++++++
.../apache/pekko/serialization/Serialization.scala | 49 ++++++----
.../serialization/WrappedPayloadSupport.scala | 10 +-
.../serialization/NestedPayloadDepthSpec.scala | 107 +++++++++++++++++++++
5 files changed, 216 insertions(+), 22 deletions(-)
diff --git a/actor/src/main/resources/reference.conf
b/actor/src/main/resources/reference.conf
index ae0ab55625..fe6282851b 100644
--- a/actor/src/main/resources/reference.conf
+++ b/actor/src/main/resources/reference.conf
@@ -777,6 +777,13 @@ pekko {
#
allow-java-serialization = off
+ # Maximum nesting depth when a serialized payload encloses another
serialized
+ # payload (for example Some, Optional, Status.Failure, StatusReply, a
Throwable
+ # cause, or an ActorSelectionMessage). Each level is parsed separately, so
this is
+ # the only bound on the chain. A message nested deeper than this is
rejected with a
+ # NotSerializableException rather than failing with a StackOverflowError.
+ serialization-max-nesting-depth = 32
+
# Log warnings when the Java serialization is used to serialize messages.
# Java serialization is not very performant and should not be used in
production
# environments unless you don't care about performance and security. In
that case
diff --git
a/actor/src/main/scala/org/apache/pekko/serialization/NestedDeserialization.scala
b/actor/src/main/scala/org/apache/pekko/serialization/NestedDeserialization.scala
new file mode 100644
index 0000000000..d4d63dedcb
--- /dev/null
+++
b/actor/src/main/scala/org/apache/pekko/serialization/NestedDeserialization.scala
@@ -0,0 +1,65 @@
+/*
+ * 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.NotSerializableException
+
+import org.apache.pekko.annotation.InternalApi
+
+/**
+ * INTERNAL API
+ *
+ * Bounds how deeply one deserialization may nest.
+ *
+ * Several wire formats carry a nested payload: the enclosed message is an
opaque byte
+ * array that is deserialized in turn, and that payload may itself enclose
another. Each
+ * level is parsed on its own, so a protobuf parser's nesting limit does not
bound the
+ * chain - only the size of the message does. Recursion therefore tracks the
nesting
+ * rather than the size of the message, and a sufficiently deep chain fails
with a
+ * `StackOverflowError` instead of a serialization error.
+ *
+ * The depth is per thread because one message is deserialized synchronously
on one thread.
+ */
+@InternalApi
+private[pekko] object NestedDeserialization {
+
+ // a one-element Array[Int] serves as a mutable int holder, so incrementing
the
+ // depth does not box the way a ThreadLocal[Int] would on every get/set
+ private val depth = new ThreadLocal[Array[Int]] {
+ override def initialValue(): Array[Int] = new Array[Int](1)
+ }
+
+ /**
+ * Runs `body` one level deeper, failing with `NotSerializableException` -
the ordinary
+ * way a message that cannot be deserialized is reported - when the nesting
exceeds `max`.
+ */
+ def atNextLevel[T](max: Int)(body: => T): T = {
+ val counter = depth.get()
+ counter(0) += 1
+ try {
+ if (counter(0) > max)
+ throw new NotSerializableException(
+ s"Message exceeds the maximum deserialization nesting depth of
[$max]. " +
+ "Configure with 'pekko.actor.serialization-max-nesting-depth'.")
+ body
+ } finally counter(0) -= 1
+ }
+
+ /** Current nesting depth, for testing. */
+ def currentDepth: Int = depth.get()(0)
+}
diff --git
a/actor/src/main/scala/org/apache/pekko/serialization/Serialization.scala
b/actor/src/main/scala/org/apache/pekko/serialization/Serialization.scala
index b823597437..b0b9e1e678 100644
--- a/actor/src/main/scala/org/apache/pekko/serialization/Serialization.scala
+++ b/actor/src/main/scala/org/apache/pekko/serialization/Serialization.scala
@@ -156,6 +156,13 @@ class Serialization(val system: ExtendedActorSystem)
extends Extension {
val log: LoggingAdapter = _log
private val manifestCache = new AtomicReference[Map[String,
Option[Class[_]]]](Map.empty[String, Option[Class[_]]])
+ /**
+ * INTERNAL API: maximum nesting depth for a payload that encloses another
serialized payload.
+ */
+ @InternalApi
+ private[pekko] val maxNestingDepth: Int =
+
system.settings.config.getInt("pekko.actor.serialization-max-nesting-depth")
+
/** INTERNAL API */
@InternalApi private[pekko] def serializationInformation:
Serialization.Information =
system.provider.serializationInformation
@@ -227,27 +234,29 @@ class Serialization(val system: ExtendedActorSystem)
extends Extension {
}
withTransportInformation { () =>
- serializer match {
- case s2: SerializerWithStringManifest => s2.fromBinary(bytes, manifest)
- case s1 =>
- if (manifest == "")
- s1.fromBinary(bytes, None)
- else {
- val cache = manifestCache.get
- cache.get(manifest) match {
- case Some(cachedClassManifest) => s1.fromBinary(bytes,
cachedClassManifest)
- case None =>
- system.dynamicAccess.getClassFor[AnyRef](manifest) match {
- case Success(classManifest) =>
- val classManifestOption: Option[Class[_]] =
Some(classManifest)
- updateCache(cache, manifest, classManifestOption)
- s1.fromBinary(bytes, classManifestOption)
- case Failure(_) =>
- throw new NotSerializableException(
- s"Cannot find manifest class [$manifest] for serializer
with id [${serializer.identifier}].")
- }
+ NestedDeserialization.atNextLevel(maxNestingDepth) {
+ serializer match {
+ case s2: SerializerWithStringManifest => s2.fromBinary(bytes,
manifest)
+ case s1 =>
+ if (manifest == "")
+ s1.fromBinary(bytes, None)
+ else {
+ val cache = manifestCache.get
+ cache.get(manifest) match {
+ case Some(cachedClassManifest) => s1.fromBinary(bytes,
cachedClassManifest)
+ case None =>
+ system.dynamicAccess.getClassFor[AnyRef](manifest) match {
+ case Success(classManifest) =>
+ val classManifestOption: Option[Class[_]] =
Some(classManifest)
+ updateCache(cache, manifest, classManifestOption)
+ s1.fromBinary(bytes, classManifestOption)
+ case Failure(_) =>
+ throw new NotSerializableException(
+ s"Cannot find manifest class [$manifest] for
serializer with id [${serializer.identifier}].")
+ }
+ }
}
- }
+ }
}
}
}
diff --git
a/remote/src/main/scala/org/apache/pekko/remote/serialization/WrappedPayloadSupport.scala
b/remote/src/main/scala/org/apache/pekko/remote/serialization/WrappedPayloadSupport.scala
index eadcafc96e..47948b774b 100644
---
a/remote/src/main/scala/org/apache/pekko/remote/serialization/WrappedPayloadSupport.scala
+++
b/remote/src/main/scala/org/apache/pekko/remote/serialization/WrappedPayloadSupport.scala
@@ -22,6 +22,7 @@ import pekko.remote.ContainerFormats
import pekko.serialization.ByteBufferSerializer
import pekko.serialization.{ SerializationExtension, Serializers }
import pekko.serialization.DisabledJavaSerializer
+import pekko.serialization.NestedDeserialization
import pekko.serialization.Serialization
import pekko.serialization.SerializerWithStringManifest
@@ -93,15 +94,20 @@ private[pekko] object WrappedPayloadSupport {
def deserializePayload(payload: ContainerFormats.Payload, serialization:
Serialization): Any = {
val manifest = if (payload.hasMessageManifest)
payload.getMessageManifest.toStringUtf8 else ""
+ // A payload may enclose another payload; bound how deep that can go. The
level is counted
+ // where a serializer is actually invoked: the two branches below call one
directly and count
+ // it here, while the third delegates to `Serialization`, which counts it
itself.
+ val max = serialization.maxNestingDepth
serialization.serializerByIdentity(payload.getSerializerId) match {
case serializer: ByteBufferSerializer =>
// may avoid one copy of the serialized payload if the proto byte is
the right kind and the
// underlying payload serializer handles byte buffers
val buffer = payload.getEnclosedMessage.asReadOnlyByteBuffer()
buffer.order(ByteOrder.LITTLE_ENDIAN)
- serializer.fromBinary(buffer, manifest)
+ NestedDeserialization.atNextLevel(max)(serializer.fromBinary(buffer,
manifest))
case serializer: SerializerWithStringManifest =>
- serializer.fromBinary(payload.getEnclosedMessage.toByteArray, manifest)
+ NestedDeserialization.atNextLevel(max)(
+ serializer.fromBinary(payload.getEnclosedMessage.toByteArray,
manifest))
case _ =>
// only old class based manifest serializers?
serialization.deserialize(payload.getEnclosedMessage.toByteArray,
payload.getSerializerId, manifest).get
diff --git
a/remote/src/test/scala/org/apache/pekko/remote/serialization/NestedPayloadDepthSpec.scala
b/remote/src/test/scala/org/apache/pekko/remote/serialization/NestedPayloadDepthSpec.scala
new file mode 100644
index 0000000000..de85fab68b
--- /dev/null
+++
b/remote/src/test/scala/org/apache/pekko/remote/serialization/NestedPayloadDepthSpec.scala
@@ -0,0 +1,107 @@
+/*
+ * 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.remote.serialization
+
+import java.io.NotSerializableException
+
+import org.apache.pekko
+import pekko.actor.Status
+import pekko.protobufv3.internal.{ ByteString => ProtoByteString }
+import pekko.remote.ContainerFormats
+import pekko.serialization.SerializationExtension
+import pekko.serialization.Serializers
+import pekko.testkit.PekkoSpec
+
+class NestedPayloadDepthSpec extends PekkoSpec("""
+ pekko.actor.allow-java-serialization = off
+ """) {
+
+ private val serialization = SerializationExtension(system)
+ private val MiscSerializerId = 16
+ private val OptionManifest = "C"
+
+ private def payloadFor(obj: AnyRef): ContainerFormats.Payload = {
+ val ser = serialization.findSerializerFor(obj)
+ ContainerFormats.Payload
+ .newBuilder()
+
.setEnclosedMessage(ProtoByteString.copyFrom(serialization.serialize(obj).get))
+ .setSerializerId(ser.identifier)
+
.setMessageManifest(ProtoByteString.copyFromUtf8(Serializers.manifestFor(ser,
obj)))
+ .build()
+ }
+
+ /** Wraps a payload in one more `Some(...)` layer, as the wire format
encodes it. */
+ private def wrapInOption(inner: ContainerFormats.Payload):
ContainerFormats.Payload = {
+ val optionBytes =
ContainerFormats.Option.newBuilder().setValue(inner).build().toByteArray
+ ContainerFormats.Payload
+ .newBuilder()
+ .setEnclosedMessage(ProtoByteString.copyFrom(optionBytes))
+ .setSerializerId(MiscSerializerId)
+ .setMessageManifest(ProtoByteString.copyFromUtf8(OptionManifest))
+ .build()
+ }
+
+ private def nestedOption(depth: Int): ContainerFormats.Payload = (1 to
depth).foldLeft(payloadFor(pekko.Done))(
+ (acc, _) => wrapInOption(acc))
+
+ private def deserialize(p: ContainerFormats.Payload): AnyRef =
+ serialization.deserialize(p.getEnclosedMessage.toByteArray,
p.getSerializerId, OptionManifest).get
+
+ "Deserialization of a nested payload" must {
+
+ "accept nesting within the configured depth" in {
+ serialization.maxNestingDepth should ===(32)
+ deserialize(nestedOption(4)) should
===(Some(Some(Some(Some(pekko.Done)))))
+ }
+
+ "reject nesting beyond the configured depth with NotSerializableException"
in {
+ // Deeper than the limit, but still a tiny message: without a bound the
depth of
+ // this recursion is limited only by the stack.
+ val ex = intercept[NotSerializableException] {
+ deserialize(nestedOption(200))
+ }
+ ex.getMessage should include("nesting depth")
+ }
+
+ "reject deep nesting that would otherwise exhaust the stack" in {
+ val deep = nestedOption(20000)
+ withClue(s"payload is only ${deep.getEnclosedMessage.size()} bytes: ") {
+ intercept[NotSerializableException] {
+ deserialize(deep)
+ }
+ }
+ }
+
+ "not leak depth between messages" in {
+ intercept[NotSerializableException](deserialize(nestedOption(200)))
+ // a later, well-formed message must still be accepted
+ deserialize(nestedOption(4)) should
===(Some(Some(Some(Some(pekko.Done)))))
+ }
+
+ "still deserialize ordinary wrapped messages" in {
+ val failure = Status.Failure(new IllegalArgumentException("boom"))
+ val roundTripped = serialization
+ .deserialize(
+ serialization.serialize(failure).get,
+ serialization.findSerializerFor(failure).identifier,
+ Serializers.manifestFor(serialization.findSerializerFor(failure),
failure))
+ .get
+ roundTripped.getClass should ===(classOf[Status.Failure])
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]