From 829943c756fc330f34f422303b1884ac658ff4c6 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Thu, 3 Sep 2026 07:55:30 +0100 Subject: [PATCH 1/2] fix: default the Jackson max-decompressed-size to unlimited Motivation: A bounded default could reject a payload an existing system legitimately exchanges, so a patch release carrying the 256 MiB default from #3491 could break running systems on upgrade. The bound should be opt-in, matching the change made to pekko.serialization.max-decompressed-size in #3502. Modification: Default pekko.serialization.jackson.compression.max-decompressed-size (and the jackson3 equivalent) to -1, meaning no limit and matching the behaviour of releases before #3491. A negative maximum skips the gzip size check and the LZ4 declared-size check; a negative declared LZ4 size is still rejected, since it is malformed regardless of the limit. Config's getBytes refuses negative numbers, so the setting is read as a plain long first and as a memory size only when that is not a negative number. Result: Jackson payload decompression is unbounded by default; configuring a size such as 256 MiB bounds it. Tests: - sbt "serialization-jackson/testOnly org.apache.pekko.serialization.jackson.*" - 122 passed - sbt "serialization-jackson3/testOnly org.apache.pekko.serialization.jackson3.*" - 120 passed - sbt "serialization-jackson/scalafmtCheckAll" "serialization-jackson3/scalafmtCheckAll" - clean - sbt "serialization-jackson/mimaReportBinaryIssues" - no issues References: Refs #3491, Refs #3502 --- .../src/main/resources/reference.conf | 5 +++- .../jackson/JacksonSerializer.scala | 16 +++++++++---- .../jackson/JacksonSerializerSpec.scala | 24 +++++++++++++++++++ .../src/main/resources/reference.conf | 5 +++- .../jackson3/JacksonSerializer.scala | 16 +++++++++---- .../jackson3/JacksonSerializerSpec.scala | 24 +++++++++++++++++++ 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/serialization-jackson/src/main/resources/reference.conf b/serialization-jackson/src/main/resources/reference.conf index 5ad52a7110..a4ef715406 100644 --- a/serialization-jackson/src/main/resources/reference.conf +++ b/serialization-jackson/src/main/resources/reference.conf @@ -210,7 +210,10 @@ pekko.serialization.jackson { # payload that decompresses to more than this is rejected rather than # allocated, guarding against a small message that inflates without bound. # This applies on deserialization regardless of the `algorithm` setting above. - max-decompressed-size = 256 MiB + # The default of -1 applies no limit, preserving the behaviour of earlier + # releases; set a size such as `256 MiB` to bound decompression, choosing a + # value larger than any payload the system legitimately exchanges. + max-decompressed-size = -1 } # Whether the type should be written to the manifest. 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..8b1f0e8e4d 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 @@ -207,7 +207,12 @@ import pekko.util.OptionVal """"off" or "gzip"""") } } - private val maxDecompressedSize: Long = conf.getBytes("compression.max-decompressed-size") + // negative means no limit; getBytes refuses negative numbers, so read those first + private val maxDecompressedSize: Long = + conf.getString("compression.max-decompressed-size").toLongOption match { + case Some(n) if n < 0 => n + case _ => conf.getBytes("compression.max-decompressed-size") + } private val migrations: Map[String, JacksonMigration] = { import scala.jdk.CollectionConverters._ conf.getConfig("migrations").root.unwrapped.asScala.toMap.map { @@ -544,7 +549,10 @@ import pekko.util.OptionVal // meta.length is the decompressed size declared on the wire; a small // message can declare a huge (or negative) size and drive a large // allocation, so bound it before decompressing. - if (meta.length < 0 || meta.length > maxDecompressedSize) + if (meta.length < 0) + throw new IllegalArgumentException( + s"Compressed message declares a negative decompressed size [${meta.length}] bytes") + if (maxDecompressedSize >= 0 && meta.length > maxDecompressedSize) throw new IllegalArgumentException( s"Compressed message declares decompressed size [${meta.length}] bytes, which exceeds the maximum " + s"of [$maxDecompressedSize] bytes (pekko.serialization.jackson.compression.max-decompressed-size)") @@ -556,7 +564,7 @@ import pekko.util.OptionVal } // gunzip with a bound on the decompressed size, so a small gzip payload cannot - // inflate without limit (a "zip bomb"). + // inflate without limit (a "zip bomb"). A negative maximum applies no bound. private def gunzip(in: GZIPInputStream): Array[Byte] = { val out = new ByteArrayOutputStream() val buffer = new Array[Byte](BufferSize) @@ -564,7 +572,7 @@ import pekko.util.OptionVal var n = in.read(buffer) while (n != -1) { total += n - if (total > maxDecompressedSize) + if (maxDecompressedSize >= 0 && total > maxDecompressedSize) throw new IllegalArgumentException( s"Decompressed message exceeds the maximum of [$maxDecompressedSize] bytes " + "(pekko.serialization.jackson.compression.max-decompressed-size)") 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..b41411af10 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 @@ -687,6 +687,30 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { } ex.getMessage should include("max-decompressed-size") } + + "apply no gzip decompression limit when max-decompressed-size is -1" in withSystem(""" + pekko.serialization.jackson.jackson-json.compression { + algorithm = gzip + compress-larger-than = 0 KiB + max-decompressed-size = -1 + } + """) { sys => + val msg = SimpleCommand("0" * (8 * 1024)) + JacksonSerializer.isGZipped(serializeToBinary(msg, sys)) should ===(true) + checkSerialization(msg, sys) + } + + "apply no lz4 decompression limit when max-decompressed-size is -1" in withSystem(""" + pekko.serialization.jackson.jackson-json.compression { + algorithm = lz4 + compress-larger-than = 0 KiB + max-decompressed-size = -1 + } + """) { sys => + val msg = SimpleCommand("0" * (8 * 1024)) + JacksonSerializer.isLZ4(serializeToBinary(msg, sys)) should ===(true) + checkSerialization(msg, sys) + } } "JacksonJsonSerializer without type in manifest" should { diff --git a/serialization-jackson3/src/main/resources/reference.conf b/serialization-jackson3/src/main/resources/reference.conf index 0a94af43f2..95c8649e3e 100644 --- a/serialization-jackson3/src/main/resources/reference.conf +++ b/serialization-jackson3/src/main/resources/reference.conf @@ -191,7 +191,10 @@ pekko.serialization.jackson3 { # payload that decompresses to more than this is rejected rather than # allocated, guarding against a small message that inflates without bound. # This applies on deserialization regardless of the `algorithm` setting above. - max-decompressed-size = 256 MiB + # The default of -1 applies no limit, preserving the behaviour of earlier + # releases; set a size such as `256 MiB` to bound decompression, choosing a + # value larger than any payload the system legitimately exchanges. + max-decompressed-size = -1 } # Whether the type should be written to the manifest. 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..58b3ca212c 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 @@ -208,7 +208,12 @@ import pekko.util.OptionVal """"off" or "gzip"""") } } - private val maxDecompressedSize: Long = conf.getBytes("compression.max-decompressed-size") + // negative means no limit; getBytes refuses negative numbers, so read those first + private val maxDecompressedSize: Long = + conf.getString("compression.max-decompressed-size").toLongOption match { + case Some(n) if n < 0 => n + case _ => conf.getBytes("compression.max-decompressed-size") + } private val migrations: Map[String, JacksonMigration] = { import scala.jdk.CollectionConverters._ conf.getConfig("migrations").root.unwrapped.asScala.toMap.map { @@ -545,7 +550,10 @@ import pekko.util.OptionVal // meta.length is the decompressed size declared on the wire; a small // message can declare a huge (or negative) size and drive a large // allocation, so bound it before decompressing. - if (meta.length < 0 || meta.length > maxDecompressedSize) + if (meta.length < 0) + throw new IllegalArgumentException( + s"Compressed message declares a negative decompressed size [${meta.length}] bytes") + if (maxDecompressedSize >= 0 && meta.length > maxDecompressedSize) throw new IllegalArgumentException( s"Compressed message declares decompressed size [${meta.length}] bytes, which exceeds the maximum " + s"of [$maxDecompressedSize] bytes (pekko.serialization.jackson3.compression.max-decompressed-size)") @@ -557,7 +565,7 @@ import pekko.util.OptionVal } // gunzip with a bound on the decompressed size, so a small gzip payload cannot - // inflate without limit (a "zip bomb"). + // inflate without limit (a "zip bomb"). A negative maximum applies no bound. private def gunzip(in: GZIPInputStream): Array[Byte] = { val out = new ByteArrayOutputStream() val buffer = new Array[Byte](BufferSize) @@ -565,7 +573,7 @@ import pekko.util.OptionVal var n = in.read(buffer) while (n != -1) { total += n - if (total > maxDecompressedSize) + if (maxDecompressedSize >= 0 && total > maxDecompressedSize) throw new IllegalArgumentException( s"Decompressed message exceeds the maximum of [$maxDecompressedSize] bytes " + "(pekko.serialization.jackson3.compression.max-decompressed-size)") 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..91eb4e0206 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 @@ -632,6 +632,30 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { } ex.getMessage should include("max-decompressed-size") } + + "apply no gzip decompression limit when max-decompressed-size is -1" in withSystem(""" + pekko.serialization.jackson3.jackson-json.compression { + algorithm = gzip + compress-larger-than = 0 KiB + max-decompressed-size = -1 + } + """) { sys => + val msg = SimpleCommand("0" * (8 * 1024)) + JacksonSerializer.isGZipped(serializeToBinary(msg, sys)) should ===(true) + checkSerialization(msg, sys) + } + + "apply no lz4 decompression limit when max-decompressed-size is -1" in withSystem(""" + pekko.serialization.jackson3.jackson-json.compression { + algorithm = lz4 + compress-larger-than = 0 KiB + max-decompressed-size = -1 + } + """) { sys => + val msg = SimpleCommand("0" * (8 * 1024)) + JacksonSerializer.isLZ4(serializeToBinary(msg, sys)) should ===(true) + checkSerialization(msg, sys) + } } "JacksonJsonSerializer without type in manifest" should { From 2bce1110adc472cd19cc961816143008c97cb296 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Thu, 3 Sep 2026 13:44:52 +0100 Subject: [PATCH 2/2] also accept "unlimited" for max-decompressed-size Motivation: Review on #3515 noted that Pekko is inconsistent about unlimited spellings and an explicit keyword is clearer than a magic number, while the neighbouring read.max-document-length and read.max-token-count settings use -1. Accept both. Modification: The setting reads "unlimited" or any negative number as no limit; the reference.conf default is written as `unlimited`. Applied to both serialization-jackson and serialization-jackson3, with a test each for the keyword. Result: `max-decompressed-size = unlimited` and `= -1` both disable the bound. Tests: - sbt "serialization-jackson/testOnly org.apache.pekko.serialization.jackson.*" - 123 passed - sbt "serialization-jackson3/testOnly org.apache.pekko.serialization.jackson3.*" - 121 passed - sbt "serialization-jackson/scalafmtCheckAll" "serialization-jackson3/scalafmtCheckAll" - clean References: Refs #3515 --- .../src/main/resources/reference.conf | 9 +++++---- .../jackson/JacksonSerializer.scala | 16 ++++++++++------ .../jackson/JacksonSerializerSpec.scala | 12 ++++++++++++ .../src/main/resources/reference.conf | 9 +++++---- .../jackson3/JacksonSerializer.scala | 16 ++++++++++------ .../jackson3/JacksonSerializerSpec.scala | 12 ++++++++++++ 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/serialization-jackson/src/main/resources/reference.conf b/serialization-jackson/src/main/resources/reference.conf index a4ef715406..593a4ad0aa 100644 --- a/serialization-jackson/src/main/resources/reference.conf +++ b/serialization-jackson/src/main/resources/reference.conf @@ -210,10 +210,11 @@ pekko.serialization.jackson { # payload that decompresses to more than this is rejected rather than # allocated, guarding against a small message that inflates without bound. # This applies on deserialization regardless of the `algorithm` setting above. - # The default of -1 applies no limit, preserving the behaviour of earlier - # releases; set a size such as `256 MiB` to bound decompression, choosing a - # value larger than any payload the system legitimately exchanges. - max-decompressed-size = -1 + # The default of `unlimited` applies no limit, preserving the behaviour of + # earlier releases; a negative number such as -1 also means unlimited. Set a + # size such as `256 MiB` to bound decompression, choosing a value larger than + # any payload the system legitimately exchanges. + max-decompressed-size = unlimited } # Whether the type should be written to the manifest. 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 8b1f0e8e4d..22145470a1 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 @@ -207,12 +207,16 @@ import pekko.util.OptionVal """"off" or "gzip"""") } } - // negative means no limit; getBytes refuses negative numbers, so read those first - private val maxDecompressedSize: Long = - conf.getString("compression.max-decompressed-size").toLongOption match { - case Some(n) if n < 0 => n - case _ => conf.getBytes("compression.max-decompressed-size") - } + // "unlimited" or a negative number means no limit; getBytes refuses both, so read them first + private val maxDecompressedSize: Long = { + val raw = conf.getString("compression.max-decompressed-size") + if (raw == "unlimited") -1L + else + raw.toLongOption match { + case Some(n) if n < 0 => n + case _ => conf.getBytes("compression.max-decompressed-size") + } + } private val migrations: Map[String, JacksonMigration] = { import scala.jdk.CollectionConverters._ conf.getConfig("migrations").root.unwrapped.asScala.toMap.map { 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 b41411af10..87dcef6b6d 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 @@ -711,6 +711,18 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { JacksonSerializer.isLZ4(serializeToBinary(msg, sys)) should ===(true) checkSerialization(msg, sys) } + + "apply no decompression limit when max-decompressed-size is unlimited" in withSystem(""" + pekko.serialization.jackson.jackson-json.compression { + algorithm = gzip + compress-larger-than = 0 KiB + max-decompressed-size = unlimited + } + """) { sys => + val msg = SimpleCommand("0" * (8 * 1024)) + JacksonSerializer.isGZipped(serializeToBinary(msg, sys)) should ===(true) + checkSerialization(msg, sys) + } } "JacksonJsonSerializer without type in manifest" should { diff --git a/serialization-jackson3/src/main/resources/reference.conf b/serialization-jackson3/src/main/resources/reference.conf index 95c8649e3e..18c37b5e9e 100644 --- a/serialization-jackson3/src/main/resources/reference.conf +++ b/serialization-jackson3/src/main/resources/reference.conf @@ -191,10 +191,11 @@ pekko.serialization.jackson3 { # payload that decompresses to more than this is rejected rather than # allocated, guarding against a small message that inflates without bound. # This applies on deserialization regardless of the `algorithm` setting above. - # The default of -1 applies no limit, preserving the behaviour of earlier - # releases; set a size such as `256 MiB` to bound decompression, choosing a - # value larger than any payload the system legitimately exchanges. - max-decompressed-size = -1 + # The default of `unlimited` applies no limit, preserving the behaviour of + # earlier releases; a negative number such as -1 also means unlimited. Set a + # size such as `256 MiB` to bound decompression, choosing a value larger than + # any payload the system legitimately exchanges. + max-decompressed-size = unlimited } # Whether the type should be written to the manifest. 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 58b3ca212c..3b2adc16ce 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 @@ -208,12 +208,16 @@ import pekko.util.OptionVal """"off" or "gzip"""") } } - // negative means no limit; getBytes refuses negative numbers, so read those first - private val maxDecompressedSize: Long = - conf.getString("compression.max-decompressed-size").toLongOption match { - case Some(n) if n < 0 => n - case _ => conf.getBytes("compression.max-decompressed-size") - } + // "unlimited" or a negative number means no limit; getBytes refuses both, so read them first + private val maxDecompressedSize: Long = { + val raw = conf.getString("compression.max-decompressed-size") + if (raw == "unlimited") -1L + else + raw.toLongOption match { + case Some(n) if n < 0 => n + case _ => conf.getBytes("compression.max-decompressed-size") + } + } private val migrations: Map[String, JacksonMigration] = { import scala.jdk.CollectionConverters._ conf.getConfig("migrations").root.unwrapped.asScala.toMap.map { 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 91eb4e0206..d094246f2f 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 @@ -656,6 +656,18 @@ class JacksonJsonSerializerSpec extends JacksonSerializerSpec("jackson-json") { JacksonSerializer.isLZ4(serializeToBinary(msg, sys)) should ===(true) checkSerialization(msg, sys) } + + "apply no decompression limit when max-decompressed-size is unlimited" in withSystem(""" + pekko.serialization.jackson3.jackson-json.compression { + algorithm = gzip + compress-larger-than = 0 KiB + max-decompressed-size = unlimited + } + """) { sys => + val msg = SimpleCommand("0" * (8 * 1024)) + JacksonSerializer.isGZipped(serializeToBinary(msg, sys)) should ===(true) + checkSerialization(msg, sys) + } } "JacksonJsonSerializer without type in manifest" should {