From bcf9af1b84c31d4fb3c14d607a73b7b3a498fc8a Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Thu, 27 Aug 2026 11:35:05 +0100 Subject: [PATCH 1/2] parse Content-Length as digits with surrounding whitespace only (#1221) Motivation: `ContentLengthParser` skipped whitespace anywhere in the field value, so `Content-Length: 1 2` was read as 12 and `Content-Length: 5 5` as 55, and an empty value was read as 0. The field value is `1*DIGIT` surrounded by optional whitespace (RFC 9110, section 8.6 and RFC 9112, section 5). Every other length ambiguity is rejected by this parser already: two differing Content-Length headers, a Transfer-Encoding other than a single chunked, and chunked together with a Content-Length. This was the remaining spot where pekko-http could read a body length that another implementation in the request path reads differently or rejects. Modification: Skip whitespace before and after the digits, but require at least one digit and stop the value at the first non-digit. Result: A Content-Length value with whitespace between digits, or without any digit, is rejected with the "Illegal `Content-Length` header value" error that other malformed values already produce. Values with leading or trailing whitespace keep parsing as before. Note this rejects two inputs that were accepted before: whitespace inside the digits, and an empty value that was read as 0. Tests: - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.parsing.RequestParserCRLFSpec org.apache.pekko.http.impl.engine.parsing.RequestParserLFSpec org.apache.pekko.http.impl.engine.parsing.ResponseParserSpec" - pass, 2 new tests that both fail without the change; leading whitespace stays covered by the existing "Content-length: 17" tests - sbt http-core/test - pass - sbt http-tests/test - pass - sbt http-core/mimaReportBinaryIssues - pass - sbt http-core/scalafmt http-core/Test/scalafmt - clean References: None - tightens Content-Length parsing to the grammar --- .../SpecializedHeaderValueParsers.scala | 26 ++++++++++++++----- .../engine/parsing/RequestParserSpec.scala | 14 ++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/SpecializedHeaderValueParsers.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/SpecializedHeaderValueParsers.scala index 3b2977217b..0e0cec6f09 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/SpecializedHeaderValueParsers.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/SpecializedHeaderValueParsers.scala @@ -33,18 +33,32 @@ private[parsing] object SpecializedHeaderValueParsers { def specializedHeaderValueParsers = Seq(ContentLengthParser) object ContentLengthParser extends HeaderValueParser("Content-Length", maxValueCount = 1) { + // The field value is `1*DIGIT`, surrounded by optional whitespace (RFC 9110, section 8.6 and RFC 9112, + // section 5). Whitespace within the digits must not be skipped: a value like `1 2` would then be read as `12` + // here while another implementation in the request path rejects it or reads it as `1`. def apply(hhp: HttpHeaderParser, input: ByteString, valueStart: Int, onIllegalHeader: ErrorInfo => Unit) : (HttpHeader, Int) = { - @tailrec def recurse(ix: Int = valueStart, result: Long = 0): (HttpHeader, Int) = { + @tailrec def skipWhitespace(ix: Int): Int = if (WSP(byteChar(input, ix))) skipWhitespace(ix + 1) else ix + + @tailrec def digits(ix: Int, result: Long, seenDigit: Boolean): (HttpHeader, Int) = { val c = byteChar(input, ix) - if (result < 0) fail("`Content-Length` header value must not exceed 63-bit integer range") - else if (DIGIT(c)) recurse(ix + 1, result * 10 + c - '0') - else if (WSP(c)) recurse(ix + 1, result) - else if (c == '\r' && byteAt(input, ix + 1) == LF_BYTE) (`Content-Length`(result), ix + 2) + if (DIGIT(c)) { + val digit = c - '0' + if (result > (Long.MaxValue - digit) / 10) + fail("`Content-Length` header value must not exceed 63-bit integer range") + else digits(ix + 1, result * 10 + digit, seenDigit = true) + } else if (!seenDigit) fail("Illegal `Content-Length` header value") + else lineEnd(skipWhitespace(ix), result) + } + + def lineEnd(ix: Int, result: Long): (HttpHeader, Int) = { + val c = byteChar(input, ix) + if (c == '\r' && byteAt(input, ix + 1) == LF_BYTE) (`Content-Length`(result), ix + 2) else if (c == '\n') (`Content-Length`(result), ix + 1) else fail("Illegal `Content-Length` header value") } - recurse() + + digits(skipWhitespace(valueStart), 0, seenDigit = false) } } } 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 f7467a49e1..a0fb3dc5bf 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 @@ -647,6 +647,20 @@ abstract class RequestParserSpec(mode: String, newLine: String) extends AnyFreeS |abc""" should parseToError(BadRequest, ErrorInfo("Illegal `Content-Length` header value")) } + "with whitespace inside the Content-Length header value" in new Test { + """GET / HTTP/1.0 + |Content-Length: 1 2 + | + |abc""" should parseToError(BadRequest, ErrorInfo("Illegal `Content-Length` header value")) + } + + "with an empty Content-Length header value" in new Test { + """GET / HTTP/1.0 + |Content-Length: + | + |abc""" should parseToError(BadRequest, ErrorInfo("Illegal `Content-Length` header value")) + } + "with Content-Length > Long.MaxSize" in new Test { // content-length = (Long.MaxValue + 1) * 10, which is 0 when calculated overflow """PUT /resource/yes HTTP/1.1 From 99d6935a45b728945b35f911f93ac419660e9120 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Thu, 27 Aug 2026 18:14:09 +0100 Subject: [PATCH 2/2] backport overflow test from #1049 for the Content-Length parser #1221 rewrites the same lines that #1049 changed, so cherry picking it onto 1.4.x brings the pre-multiply overflow check along with it. #1049 was never backported, so its test came too: a value that wraps back to a small positive Long, which the old `result < 0` check did not catch. SpecializedHeaderValueParsers.scala and ContentLengthHeaderParserSpec.scala are now identical to main. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/engine/parsing/ContentLengthHeaderParserSpec.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/ContentLengthHeaderParserSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/ContentLengthHeaderParserSpec.scala index 1671d62492..2e28d92e45 100644 --- a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/ContentLengthHeaderParserSpec.scala +++ b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/parsing/ContentLengthHeaderParserSpec.scala @@ -37,6 +37,8 @@ abstract class ContentLengthHeaderParserSpec(mode: String, newLine: String) exte a[ParsingException] should be thrownBy parse("9223372036854775808") // Long.MaxValue + 1 a[ParsingException] should be thrownBy parse("92233720368547758070") // Long.MaxValue * 10 which is 0 taken overflow into account a[ParsingException] should be thrownBy parse("92233720368547758080") // (Long.MaxValue + 1) * 10 which is 0 taken overflow into account + // overflow that wraps to a small positive value (was not caught by the old `result < 0` check) + a[ParsingException] should be thrownBy parse("18446744073709551634") // ~2^64+18, wraps to 18 in signed Long } }