From e5bafc5c563aee15c58f7c11d922e2fd905709df Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Mon, 31 Aug 2026 18:15:17 +0100 Subject: [PATCH] fix: reject a message whose Transfer-Encoding value cannot be parsed Motivation: When a modelled header value fails to parse, `ModeledHeaderValueParser` calls `onIllegalHeader` - which by default only logs - and degrades the header to a `RawHeader`. For `Transfer-Encoding` that is unsafe: the degraded header never reaches the `case h: Transfer-Encoding` arm in `parseHeaderLines`, so `isChunked` stays false and, with a Content-Length also present, the message is framed by Content-Length. An upstream that does parse the value - stripping quotes, tolerating trailing junk - frames the same message as chunked. The two disagree about where the message ends, which is a request smuggling primitive. Pekko HTTP already rejects the clear cases: `chunked` together with a Content-Length, an unsupported coding, and multiple entries. Only the unparseable value was silently tolerated. Modification: Fail the message when a `Transfer-Encoding` header arrives as a `RawHeader`. That can only happen when the modelled parse failed: `transfer-encoding` is in `alwaysParsedHeaders`, so it is modelled even when `modeled-header-parsing` is off, and a well-formed value always reaches the modelled arm. Result: A message whose Transfer-Encoding cannot be understood is rejected rather than framed by a different rule than the sender used. Tests: - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.parsing.*" - pass (246 tests); a new test sends `Transfer-Encoding: "chunked"` alongside a Content-Length and expects a 400. Verified it fails without the change, where the message is accepted and framed by Content-Length. - sbt http-core/mimaReportBinaryIssues - pass References: None - rejects a message whose Transfer-Encoding value cannot be parsed --- .../http/impl/engine/parsing/HttpMessageParser.scala | 7 +++++++ .../http/impl/engine/parsing/RequestParserSpec.scala | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpMessageParser.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpMessageParser.scala index 110564916..6d0bd2f15 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpMessageParser.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpMessageParser.scala @@ -204,6 +204,13 @@ private[http] trait HttpMessageParser[Output >: MessageOutput <: ParserOutput] { // only allow one 'chunked' failMessageStart("Multiple Transfer-Encoding entries not supported") } + + // `transfer-encoding` is always modelled (it is in `alwaysParsedHeaders`), so it only reaches us as a + // RawHeader when its value failed to parse and was degraded to one. Framing must not silently fall back to + // Content-Length then: an upstream that does understand the value would frame the message differently, which + // is a request smuggling discrepancy. + case h: RawHeader if h.lowercaseName == "transfer-encoding" => + failMessageStart("Illegal `Transfer-Encoding` header value") case h: Connection => ch match { case None => parseHeaderLines(input, lineEnd, headers += h, headerCount + 1, Some(h), clh, cth, isChunked, e100c, hh) diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/RequestParserSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/RequestParserSpec.scala index d7e903e03..9826b849a 100644 --- a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/RequestParserSpec.scala +++ b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/RequestParserSpec.scala @@ -663,6 +663,17 @@ abstract class RequestParserSpec(mode: String, newLine: String) extends AnyFreeS ErrorInfo("HTTP header value exceeds the configured limit of 32 characters")) } + "with an unparseable Transfer-Encoding header value" in new Test { + // the value cannot be modelled, so it would otherwise degrade to a RawHeader and the message would be framed + // by Content-Length while an upstream that does understand it frames by chunked encoding + """POST / HTTP/1.1 + |Host: x + |Transfer-Encoding: "chunked" + |Content-Length: 3 + | + |abc""" should parseToError(BadRequest, ErrorInfo("Illegal `Transfer-Encoding` header value")) + } + "with an invalid Content-Length header value" in new Test { """GET / HTTP/1.0 |Content-Length: 1.5