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 347264aa0f fix: check the remote deployment allow list before
deserializing constructor args (#3493)
347264aa0f is described below
commit 347264aa0fa82bedc87250fbc76765107f0d2780
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 31 22:01:03 2026 +0100
fix: check the remote deployment allow list before deserializing
constructor args (#3493)
Motivation:
DaemonMsgCreateSerializer.fromBinary loaded the peer-named actor class and
then
deserialized every constructor argument - with peer-chosen serializer ids
and
manifests - before RemoteSystemDaemon consulted the remote deployment allow
list. For a class the allow list rejects, that deserialization is attack
surface
taken on for a deployment that is refused moments later, which weakens the
guarantee the allow list is credited with.
Modification:
Extract RemoteDeploymentAllowList, holding the two existing config keys and
the
class-name comparison, and use it from both RemoteSystemDaemon and
DaemonMsgCreateSerializer so the two cannot drift. The serializer now
checks the
allow list immediately after resolving the actor class and before
deserializing
any argument, logging at error with LogMarker.Security and the same
exception
RemoteSystemDaemon raises.
Result:
With the allow list enabled, a rejected class is refused before any
peer-supplied argument is deserialized. No new configuration, and no change
when the allow list is off, which is the default.
Tests:
- sbt "remote/testOnly
org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerAllowListSpec
org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerAllowListDisabledSpec"
- new, incl. a counting serializer asserting args are not deserialized for a
rejected class
- sbt "remote/testOnly
org.apache.pekko.remote.classic.RemoteDeploymentAllowListSpec
org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerAllowJavaSerializationSpec
org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerNoJavaSerializationSpec"
- existing specs pass unchanged
- sbt "remote/mimaReportBinaryIssues" - no issues
References:
Refs #3478
---
.../org/apache/pekko/remote/RemoteDaemon.scala | 50 +++++--
.../serialization/DaemonMsgCreateSerializer.scala | 26 ++++
.../DaemonMsgCreateSerializerAllowListSpec.scala | 146 +++++++++++++++++++++
3 files changed, 212 insertions(+), 10 deletions(-)
diff --git a/remote/src/main/scala/org/apache/pekko/remote/RemoteDaemon.scala
b/remote/src/main/scala/org/apache/pekko/remote/RemoteDaemon.scala
index 34ac5b41e5..16885a6394 100644
--- a/remote/src/main/scala/org/apache/pekko/remote/RemoteDaemon.scala
+++ b/remote/src/main/scala/org/apache/pekko/remote/RemoteDaemon.scala
@@ -17,6 +17,7 @@ import java.util.concurrent.ConcurrentHashMap
import scala.annotation.tailrec
import scala.collection.immutable
+import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
import org.apache.pekko
@@ -42,11 +43,14 @@ import pekko.actor.Identify
import pekko.actor.SelectChildName
import pekko.actor.SelectChildPattern
import pekko.actor.SelectParent
+import pekko.annotation.InternalApi
import pekko.dispatch.sysmsg.{ DeathWatchNotification, SystemMessage, Watch }
import pekko.dispatch.sysmsg.Unwatch
import pekko.event.{ AddressTerminatedTopic, LogMarker, MarkerLoggingAdapter }
import pekko.util.Switch
+import com.typesafe.config.Config
+
/**
* INTERNAL API
*/
@@ -83,13 +87,8 @@ private[pekko] class RemoteSystemDaemon(
private val parent2children = new ConcurrentHashMap[ActorRef, Set[ActorRef]]
- private val allowListEnabled =
system.settings.config.getBoolean("pekko.remote.deployment.enable-allow-list")
- private val remoteDeploymentAllowList: immutable.Set[String] = {
- import scala.jdk.CollectionConverters._
- if (allowListEnabled)
-
system.settings.config.getStringList("pekko.remote.deployment.allowed-actor-classes").asScala.toSet
- else Set.empty
- }
+ private val allowList = RemoteDeploymentAllowList(system.settings.config)
+ private def allowListEnabled = allowList.enabled
@tailrec private def addChildParentNeedsWatch(parent: ActorRef, child:
ActorRef): Boolean =
parent2children.get(parent) match {
@@ -176,12 +175,11 @@ private[pekko] class RemoteSystemDaemon(
log.debug("does not accept deployments (untrusted) for [{}]",
path) // TODO add security marker?
case DaemonMsgCreate(props, deploy, path, supervisor) if
allowListEnabled =>
- val name = props.clazz.getCanonicalName
- if (remoteDeploymentAllowList.contains(name))
+ if (allowList.isAllowed(props.clazz))
doCreateActor(message, props, deploy, path, supervisor)
else {
val ex =
- new
NotAllowedClassRemoteDeploymentAttemptException(props.actorClass(),
remoteDeploymentAllowList)
+ new
NotAllowedClassRemoteDeploymentAttemptException(props.actorClass(),
allowList.allowedClassNames)
log.error(
LogMarker.Security,
ex,
@@ -284,6 +282,38 @@ private[pekko] class RemoteSystemDaemon(
}
+/**
+ * INTERNAL API
+ *
+ * The remote deployment allow list, shared by `RemoteSystemDaemon` and
+ * `DaemonMsgCreateSerializer` so that both read the same configuration and
apply the
+ * same class-name comparison.
+ */
+@InternalApi
+private[pekko] object RemoteDeploymentAllowList {
+ final val EnableAllowListPath = "pekko.remote.deployment.enable-allow-list"
+ final val AllowedActorClassesPath =
"pekko.remote.deployment.allowed-actor-classes"
+
+ def apply(config: Config): RemoteDeploymentAllowList = {
+ val enabled = config.getBoolean(EnableAllowListPath)
+ val allowed =
+ if (enabled) config.getStringList(AllowedActorClassesPath).asScala.toSet
+ else immutable.Set.empty[String]
+ new RemoteDeploymentAllowList(enabled, allowed)
+ }
+}
+
+/** INTERNAL API */
+@InternalApi
+private[pekko] final class RemoteDeploymentAllowList(
+ val enabled: Boolean,
+ val allowedClassNames: immutable.Set[String]) {
+
+ /** True when the allow list is disabled, or the class is listed on it. */
+ def isAllowed(actorClass: Class[?]): Boolean =
+ !enabled || allowedClassNames.contains(actorClass.getCanonicalName)
+}
+
/** INTERNAL API */
final class NotAllowedClassRemoteDeploymentAttemptException(illegal: Class[?],
allowedClassNames: immutable.Set[String])
extends RuntimeException(
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 e619eb29ec..d956d110cf 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
@@ -20,9 +20,12 @@ import util.{ Failure, Success }
import org.apache.pekko
import pekko.actor.{ Deploy, ExtendedActorSystem, NoScopeGiven, Props, Scope }
+import pekko.event.{ LogMarker, Logging }
import pekko.protobufv3.internal.ByteString
import pekko.remote.ByteStringUtils
import pekko.remote.DaemonMsgCreate
+import pekko.remote.NotAllowedClassRemoteDeploymentAttemptException
+import pekko.remote.RemoteDeploymentAllowList
import pekko.remote.WireFormats.{ DaemonMsgCreateData, DeployData, PropsData }
import pekko.routing.{ NoRouter, RouterConfig }
import pekko.serialization.{ BaseSerializer, SerializationExtension,
SerializerWithStringManifest }
@@ -43,6 +46,9 @@ private[pekko] final class DaemonMsgCreateSerializer(val
system: ExtendedActorSy
import ProtobufSerializer.serializeActorRef
private lazy val serialization = SerializationExtension(system)
+ private lazy val log = Logging.withMarker(system,
classOf[DaemonMsgCreateSerializer])
+
+ private val allowList = RemoteDeploymentAllowList(system.settings.config)
override val includeManifest: Boolean = false
@@ -174,6 +180,12 @@ private[pekko] final class DaemonMsgCreateSerializer(val
system: ExtendedActorSy
import scala.jdk.CollectionConverters._
val protoProps = proto.getProps
val actorClass =
system.dynamicAccess.getClassFor[AnyRef](protoProps.getClazz).get
+ // Check the allow list before deserializing the constructor arguments
below. Those
+ // arguments are peer-supplied and are deserialized with peer-chosen
serializer ids and
+ // manifests, so for a class the allow list would reject that work is
attack surface
+ // taken on for a deployment that will be refused anyway.
`RemoteSystemDaemon` performs
+ // the same check when it handles the message; this one only makes it
earlier.
+ checkAllowedActorClass(actorClass)
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) {
@@ -206,6 +218,20 @@ private[pekko] final class DaemonMsgCreateSerializer(val
system: ExtendedActorSy
supervisor = deserializeActorRef(system, proto.getSupervisor))
}
+ private def checkAllowedActorClass(actorClass: Class[?]): Unit =
+ if (!allowList.isAllowed(actorClass)) {
+ val ex = new NotAllowedClassRemoteDeploymentAttemptException(actorClass,
allowList.allowedClassNames)
+ // Logged at error with the exception, matching `RemoteSystemDaemon`, so
that the
+ // security signal is the same wherever the deployment is refused.
+ log.error(
+ LogMarker.Security,
+ ex,
+ "Received command to create remote Actor, but class [{}] is not
allow-listed! " +
+ "Rejected before deserializing the constructor arguments.",
+ actorClass.getName)
+ throw ex
+ }
+
private def serialize(any: Any): (Int, Boolean, String, Array[Byte]) = {
val m = any.asInstanceOf[AnyRef]
val serializer = serialization.findSerializerFor(m)
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
new file mode 100644
index 0000000000..60a0f6c91d
--- /dev/null
+++
b/remote/src/test/scala/org/apache/pekko/remote/serialization/DaemonMsgCreateSerializerAllowListSpec.scala
@@ -0,0 +1,146 @@
+/*
+ * 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.nio.charset.StandardCharsets.UTF_8
+import java.util.concurrent.atomic.AtomicInteger
+
+import scala.annotation.nowarn
+
+import org.apache.pekko
+import pekko.actor.Actor
+import pekko.actor.Deploy
+import pekko.actor.Props
+import pekko.remote.DaemonMsgCreate
+import pekko.remote.NotAllowedClassRemoteDeploymentAttemptException
+import pekko.serialization.SerializationExtension
+import pekko.serialization.SerializerWithStringManifest
+import pekko.testkit.PekkoSpec
+
+object DaemonMsgCreateSerializerAllowListSpec {
+
+ trait EmptyActor extends Actor {
+ def receive = Actor.emptyBehavior
+ }
+
+ class AllowedActor(@nowarn("msg=never used") arg: MarkerArg) extends
EmptyActor
+ class NotAllowedActor(@nowarn("msg=never used") arg: MarkerArg) extends
EmptyActor
+ class SupervisorActor extends EmptyActor
+
+ final case class MarkerArg(value: String)
+
+ /** Counts how often a constructor argument is actually deserialized. */
+ val deserializeCount = new AtomicInteger(0)
+
+ class MarkerArgSerializer extends SerializerWithStringManifest {
+ override def identifier: Int = 987654
+ override def manifest(o: AnyRef): String = "M"
+ override def toBinary(o: AnyRef): Array[Byte] =
o.asInstanceOf[MarkerArg].value.getBytes(UTF_8)
+ override def fromBinary(bytes: Array[Byte], manifest: String): AnyRef = {
+ deserializeCount.incrementAndGet()
+ MarkerArg(new String(bytes, UTF_8))
+ }
+ }
+}
+
+class DaemonMsgCreateSerializerAllowListSpec
+ extends PekkoSpec(s"""
+ pekko.remote.deployment {
+ enable-allow-list = on
+ allowed-actor-classes = [
+
"org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerAllowListSpec.AllowedActor"
+ ]
+ }
+ pekko.actor {
+ serializers.marker-arg =
"${classOf[DaemonMsgCreateSerializerAllowListSpec.MarkerArgSerializer].getName}"
+ serialization-bindings {
+
"org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerAllowListSpec$$MarkerArg"
= marker-arg
+ }
+ }
+ """) {
+
+ import DaemonMsgCreateSerializerAllowListSpec._
+
+ private val ser = SerializationExtension(system)
+ private val supervisor = system.actorOf(Props[SupervisorActor](),
"supervisor")
+
+ private def daemonMsgCreate(actorClass: Class[?]): DaemonMsgCreate =
+ DaemonMsgCreate(
+ props = Props(actorClass, MarkerArg("payload")),
+ deploy = Deploy(),
+ path = "foo",
+ supervisor = supervisor)
+
+ "DaemonMsgCreateSerializer with the remote deployment allow list enabled"
must {
+
+ "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]
+ got.props.clazz should ===(classOf[AllowedActor])
+ got.props.args should ===(Seq(MarkerArg("payload")))
+ }
+
+ "reject a DaemonMsgCreate for a class that is not allow-listed" in {
+ val bytes = ser.serialize(daemonMsgCreate(classOf[NotAllowedActor])).get
+ val ex = intercept[NotAllowedClassRemoteDeploymentAttemptException] {
+ ser.deserialize(bytes, classOf[DaemonMsgCreate]).get
+ }
+ ex.getMessage should include("NotAllowedActor")
+ }
+
+ "not deserialize the constructor arguments of a rejected class" in {
+ val bytes = ser.serialize(daemonMsgCreate(classOf[NotAllowedActor])).get
+ deserializeCount.set(0)
+ intercept[NotAllowedClassRemoteDeploymentAttemptException] {
+ ser.deserialize(bytes, classOf[DaemonMsgCreate]).get
+ }
+ withClue("peer-supplied constructor arguments must not be deserialized
for a rejected class") {
+ deserializeCount.get should ===(0)
+ }
+ }
+ }
+}
+
+class DaemonMsgCreateSerializerAllowListDisabledSpec extends PekkoSpec(s"""
+ pekko.actor {
+ serializers.marker-arg =
"${classOf[DaemonMsgCreateSerializerAllowListSpec.MarkerArgSerializer].getName}"
+ serialization-bindings {
+
"org.apache.pekko.remote.serialization.DaemonMsgCreateSerializerAllowListSpec$$MarkerArg"
= marker-arg
+ }
+ }
+ """) {
+
+ import DaemonMsgCreateSerializerAllowListSpec._
+
+ private val ser = SerializationExtension(system)
+ private val supervisor = system.actorOf(Props[SupervisorActor](),
"supervisor")
+
+ "DaemonMsgCreateSerializer with the allow list disabled (the default)" must {
+
+ "deserialize any actor class, as before" in {
+ val msg = DaemonMsgCreate(
+ props = Props(classOf[NotAllowedActor], MarkerArg("payload")),
+ deploy = Deploy(),
+ path = "foo",
+ supervisor = supervisor)
+ val got = ser.deserialize(ser.serialize(msg).get,
classOf[DaemonMsgCreate]).get.asInstanceOf[DaemonMsgCreate]
+ got.props.clazz should ===(classOf[NotAllowedActor])
+ got.props.args should ===(Seq(MarkerArg("payload")))
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]