From b0527a3e39458f332ac71a9b5c4b9c9f1fde1cec Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sun, 6 Sep 2026 16:13:50 +0100 Subject: [PATCH] fix: start each multipart part with its own header state When a body part ends without a header-separating empty line, the `BoundaryHeader` branch of `parseHeaderLines` emits that part and continues into the next one, but passed its own `headers` and `headerCount` along. The boundary starts a new part, so both should start empty -- `cth` was already reset to `None` on the same call, which suggests the other two were simply missed. Two visible effects, both fixed here: - the following part was reported carrying headers it never declared; - `headerCount` was never reset either, so a run of such parts accumulated towards `max-header-count` across parts rather than per part, failing parts that are individually well within the limit. The reset stays a direct self-call to `parseHeaderLines`: that call is what makes the method `@tailrec`, and routing it through a helper would turn it into an unoptimised mutual recursion. Fixes #1278. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/engine/parsing/BodyPartParser.scala | 4 +- .../MultipartUnmarshallersSpec.scala | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/BodyPartParser.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/BodyPartParser.scala index 3ee26ed8e0..9e2fcd5d65 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/BodyPartParser.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/BodyPartParser.scala @@ -178,7 +178,9 @@ private[http] final class BodyPartParser( emit(BodyPartStart(headers.toList, _ => HttpEntity.empty(contentType))) val ix = lineStart + eolConfiguration.boundaryLength if (eolConfiguration.isEndOfLine(input, ix)) - parseHeaderLines(input, ix + eolConfiguration.eolLength, headers, headerCount, None) + // the boundary starts a new part, so its header state starts empty. Note this must stay a direct + // self-call: it is what keeps this method tail-recursive. + parseHeaderLines(input, ix + eolConfiguration.eolLength, ListBuffer[HttpHeader](), 0, None) else if (doubleDash(input, ix)) setShouldTerminate() else fail("Illegal multipart boundary in message content") diff --git a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala index d5cf37c320..6770b342b6 100644 --- a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala +++ b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/unmarshalling/MultipartUnmarshallersSpec.scala @@ -19,6 +19,7 @@ import scala.concurrent.duration._ import org.apache.pekko import pekko.http.impl.util._ import pekko.http.scaladsl.model._ +import pekko.http.scaladsl.settings.ParserSettings import pekko.http.scaladsl.model.MediaTypes._ import pekko.http.scaladsl.model.headers._ import pekko.http.scaladsl.util.FastFuture._ @@ -62,6 +63,45 @@ trait MultipartUnmarshallersSpec extends PekkoSpecWithMaterializer { |--XYZABC--""".stripMarginWithNewline(lineFeed)))).to[Multipart.General] should haveParts( Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/xml(UTF-8)`), List(Age(12)))) } + "consecutive parts without header separation, each keeping its own headers" in { + Unmarshal(HttpEntity( + `multipart/mixed`.withBoundary("XYZABC"), + ByteString("""--XYZABC + |Age: 12 + |--XYZABC + |Age: 13 + |--XYZABC + |--XYZABC--""".stripMarginWithNewline(lineFeed)))).to[Multipart.General] should haveParts( + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/plain(UTF-8)`), List(Age(12))), + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/plain(UTF-8)`), List(Age(13))), + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/plain(UTF-8)`))) + } + "a part without header separation not carrying its Content-Type into the next part" in { + Unmarshal(HttpEntity( + `multipart/mixed`.withBoundary("XYZABC"), + ByteString("""--XYZABC + |Content-type: text/xml; charset=UTF-8 + |--XYZABC + |--XYZABC--""".stripMarginWithNewline(lineFeed)))).to[Multipart.General] should haveParts( + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/xml(UTF-8)`)), + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/plain(UTF-8)`))) + } + "parts without header separation counting headers per part, not across parts" in { + implicit val parserSettings: ParserSettings = ParserSettings(system).withMaxHeaderCount(2) + Unmarshal(HttpEntity( + `multipart/mixed`.withBoundary("XYZABC"), + ByteString("""--XYZABC + |Age: 12 + |X-Foo: bar + |--XYZABC + |Age: 13 + |X-Foo: baz + |--XYZABC--""".stripMarginWithNewline(lineFeed)))).to[Multipart.General] should haveParts( + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/plain(UTF-8)`), + List(Age(12), RawHeader("X-Foo", "bar"))), + Multipart.General.BodyPart.Strict(HttpEntity.empty(ContentTypes.`text/plain(UTF-8)`), + List(Age(13), RawHeader("X-Foo", "baz")))) + } "an implicitly typed part (without headers) (Strict)" in { Unmarshal(HttpEntity( `multipart/mixed`.withBoundary("XYZABC"),