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-http.git


The following commit(s) were added to refs/heads/main by this push:
     new 265a6ceba fix: reject control bytes in the request line and malformed 
HTTP/2 :scheme and field names (#1341)
265a6ceba is described below

commit 265a6cebae5d0b4409f257ed72723539e1c2da71
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Oct 13 19:26:32 2026 +0100

    fix: reject control bytes in the request line and malformed HTTP/2 :scheme 
and field names (#1341)
    
    * test: cover control characters around the request-line version token
    
    Motivation:
    netty/netty#16970 was a request-smuggling bug in which control bytes around 
the HTTP
    version token were stripped before matching. #1088 covered a NUL byte in 
the request
    target, but nothing covered control bytes next to the version, in the 
method, or the
    other control characters #1087 lists.
    
    Modification:
    Add RequestParserSpec cases, run in both the CRLF and LF variants, for:
    - NUL, VT, FF and bare CR immediately before and after HTTP/1.1 (505)
    - NUL before and inside the method (501)
    - VT and FF in the request target (400)
    Inputs bypass prep so the CR cases keep their exact bytes.
    
    Result:
    A future parser change that trims control bytes around the version token 
fails these
    tests.
    
    Tests:
    - sbt "http-core/testOnly *RequestParserCRLFSpec *RequestParserLFSpec": 138 
passed
    - With HttpRequestParser temporarily changed to skip NUL/VT/FF/bare CR 
around the
      version, "a control character before or after the HTTP version" fails
    - sbt http-core/Test/scalafmtCheck: pass
    
    References:
    Fixes #1087
    
    * fix: reject request-line and HTTP/2 field bytes that re-parse differently
    
    Motivation:
    Probing the request-line parser for #1087 found that a CR or LF ending the 
request
    target was skipped as if it were the SP separator, so `GET /<LF>HTTP/1.1` 
was accepted
    as one request line where another hop may see a line without a version. The 
HTTP/2
    equivalents - control bytes in pseudo-header and field values - turned up 
two more:
    - an invalid ':scheme' (a control character or whitespace) made Uri.apply 
throw an
      IllegalUriException out of the request parsing stage, failing the whole 
connection
      instead of the one stream
    - a regular field name containing ':' was handed to the HTTP/1.1 line 
parser as
      `name: value` and split at the colon, so `x:a: v` became a field `x` 
carrying `a: v`
    
    Modification:
    - HttpRequestParser: a CR or LF ending the request target is answered with 
505, as a
      request line without a version already was.
    - Http2HeaderParsing.Scheme: validate the value during decompression, as 
':path' and
      ':authority' are, and report a malformed one as a ParsingException. An 
empty value
      is still accepted because Pekko HTTP/2 clients before #1342 sent one for a
      relative URI.
    - HeaderDecompression: reject a non-pseudo field name containing ':' (RFC 
9113 8.2.1).
    
    Result:
    These requests are rejected with a 400/505 for the affected request or 
stream; an
    invalid ':scheme' no longer tears down every stream on the connection.
    
    Tests:
    - Without the main changes the new RequestParserSpec CR/LF test and the 
RequestParsingSpec
      ':' name and ':scheme' tests fail
    - sbt http-core/test: 1331 passed
    - sbt http2-tests/test: 390 passed
    - sbt http-core/mimaReportBinaryIssues and scalafmt checks: pass
    
    References:
    Refs #1087
---
 .../engine/http2/hpack/HeaderDecompression.scala   |  4 +++
 .../engine/http2/hpack/Http2HeaderParsing.scala    | 12 ++++++-
 .../impl/engine/parsing/HttpRequestParser.scala    |  4 +++
 .../impl/engine/parsing/RequestParserSpec.scala    | 40 ++++++++++++++++++++++
 .../impl/engine/http2/RequestParsingSpec.scala     | 20 +++++++++++
 5 files changed, 79 insertions(+), 1 deletion(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala
index 408ae35a8..91df746eb 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderDecompression.scala
@@ -121,6 +121,10 @@ private[http2] final class 
HeaderDecompression(masterHeaderParser: HttpHeaderPar
                 case "cookie"         => handle(Cookie.parse(name, value, 
parserSettings))
                 case x if x(0) == ':' => handle(value)
                 case _                =>
+                  // RFC 9113 8.2.1: only a pseudo-header name may contain a 
colon. The HTTP/1.1 line parser below would
+                  // otherwise split `x:a` at it and read a field named `x` 
carrying `a: <value>`.
+                  if (name.indexOf(':') >= 0)
+                    throw new ParsingException(ErrorInfo("Malformed request: 
header field name must not contain ':'"))
                   // cannot use OtherHeader.parse because that doesn't has 
access to header parser
                   val header = parseHeaderPair(httpHeaderParser, name, value)
                   RequestParsing.validateHeader(header)
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/Http2HeaderParsing.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/Http2HeaderParsing.scala
index 7b2066065..7294216a7 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/Http2HeaderParsing.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/Http2HeaderParsing.scala
@@ -32,7 +32,17 @@ private[pekko] object Http2HeaderParsing {
     override def parse(name: String, value: String, parserSettings: 
ParserSettings): String = value
   }
 
-  object Scheme extends Verbatim(":scheme")
+  object Scheme extends HeaderParser[String](":scheme") {
+    // validated here, like ':path' and ':authority', so that a malformed 
value fails only its own stream: left to
+    // `Uri.apply` in RequestParsing, the IllegalUriException escapes the 
request parsing stage and with it the
+    // whole connection fails. An empty value is let through, since Pekko HTTP 
clients before #1342 sent one for a
+    // request with a relative URI.
+    override def parse(name: String, value: String, parserSettings: 
ParserSettings): String =
+      try Uri.normalizeScheme(value)
+      catch {
+        case IllegalUriException(info) => throw new ParsingException(info)
+      }
+  }
   object Method extends HeaderParser[HttpMethod](":method") {
     override def parse(name: String, value: String, parserSettings: 
ParserSettings): HttpMethod =
       HttpMethods.getForKey(value)
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpRequestParser.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpRequestParser.scala
index e43fe3954..2b30dc13e 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpRequestParser.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/parsing/HttpRequestParser.scala
@@ -184,6 +184,10 @@ private[http] final class HttpRequestParser(
             s"URI length exceeds the configured limit of $maxUriLength 
characters$remoteAddressStr")
 
         val uriEnd = findUriEnd()
+        // A CR or LF here ends the request line before any HTTP version. 
Skipping it as if it were the SP separator
+        // would read `GET /<LF>HTTP/1.1` as one request line where another 
hop may see a line without a version.
+        val separator = input(uriEnd)
+        if (separator == CR_BYTE || separator == LF_BYTE) 
onBadProtocol(input.drop(uriEnd))
         try {
           uriBytes = input.slice(uriStart, uriEnd)
           uriParser.reset(new ByteStringParserInput(uriBytes))
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 9e85e2342..478fe399c 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
@@ -691,6 +691,34 @@ abstract class RequestParserSpec(mode: String, newLine: 
String) extends AnyFreeS
         }
       }
 
+      // A parser that strips control bytes around the version token before 
matching it can be made to agree with
+      // a request line that another hop rejects 
(https://github.com/netty/netty/issues/16970). The inputs below are
+      // not passed through `prep`, so that the CR cases keep their exact 
bytes in both line-ending variants.
+      "a control character before or after the HTTP version" in new Test {
+        for {
+          control <- Seq("\u0000", "\u000b", "\u000c", "\r")
+          requestLine <- Seq(s"GET / ${control}HTTP/1.1", s"GET / 
HTTP/1.1$control")
+        } rejectRawRequestLine(s"$requestLine\r\nHost: x\r\n\r\n", 
HttpVersionNotSupported,
+          "The server does not support the HTTP protocol version")
+      }
+
+      "a CR or LF between the request target and the HTTP version" in new Test 
{
+        // either ends the request line before the version, so it must not be 
skipped as if it were the SP separator
+        for (separator <- Seq("\r", "\n"))
+          rejectRawRequestLine(s"GET /${separator}HTTP/1.1\r\nHost: 
x\r\n\r\n", HttpVersionNotSupported,
+            "The server does not support the HTTP protocol version")
+      }
+
+      "a NUL byte before or in the HTTP method" in new Test {
+        for (requestLine <- Seq("\u0000GET / HTTP/1.1", "G\u0000ET / 
HTTP/1.1"))
+          rejectRawRequestLine(s"$requestLine\r\nHost: x\r\n\r\n", 
NotImplemented, "Unsupported HTTP method")
+      }
+
+      "a VT or FF byte in the request target" in new Test {
+        for (control <- Seq("\u000b", "\u000c"))
+          rejectRawRequestLine(s"GET /$control HTTP/1.1\r\nHost: x\r\n\r\n", 
BadRequest, "Illegal request-target")
+      }
+
       "with an illegal char in a header name" in new Test {
         """GET / HTTP/1.1
           |User@Agent: curl/7.19.7""" should parseToError(BadRequest, 
ErrorInfo("Illegal character '@' in header name"))
@@ -930,6 +958,18 @@ abstract class RequestParserSpec(mode: String, newLine: 
String) extends AnyFreeS
     def rawMultiParseTo(parser: HttpRequestParser, expected: HttpRequest*): 
Matcher[Seq[String]] =
       generalRawMultiParseTo(parser, expected.map(Right(_)): _*)
 
+    def rejectRawRequestLine(input: String, status: StatusCode, summaryPrefix: 
String): Unit = {
+      val result = multiParse(newParser)(Seq(input))
+      result.length shouldEqual 1
+      result.head match {
+        case Left(MessageStartError(`status`, info, _)) => info.summary should 
startWith(summaryPrefix)
+        case other                                      =>
+          fail(s"Expected a $status MessageStartError for ${input.flatMap(c =>
+              if (c < 0x20) f"\\u${c.toInt}%04x" else c.toString)}" +
+            s" but got $other")
+      }
+    }
+
     def parseToError(status: StatusCode, info: ErrorInfo): Matcher[String] =
       generalMultiParseTo(Left(MessageStartError(status, info))).compose(_ :: 
Nil)
 
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala
index 1af04d359..63fc77c6d 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/RequestParsingSpec.scala
@@ -158,6 +158,26 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           parseExpectError(request("x-a" -> value)).summary should ===(
             "header field value must not start or end with whitespace")
       }
+      "a header value containing another control character" in {
+        for (control <- Seq("\u0001", "\u000b", "\u000c")) {
+          val info = parseExpectError(request("x-a" -> s"foo${control}bar"))
+          info.summary should include("Illegal character")
+        }
+      }
+      "a header name containing ':'" in {
+        // the HTTP/1.1 line parser this is handed to splits at the colon, so 
without the check the request was
+        // accepted with a field named `x` carrying `a: v`
+        val info = parseExpectError(request("x:a" -> "v"))
+        info.summary should include("header field name must not contain ':'")
+      }
+      "a ':scheme' containing a control character or whitespace" in {
+        // without validating it during decompression, `Uri.apply` threw an 
IllegalUriException that escaped the
+        // request parsing stage and failed the whole connection
+        for (scheme <- Seq("ht\u000btp", "ht\u000ctp", "ht\ttp", "ht tp", 
"http\u0001")) {
+          val info = parseExpectError(Vector(":method" -> "GET", ":scheme" -> 
scheme, ":path" -> "/"))
+          info.summary should include("Invalid URI scheme")
+        }
+      }
       "a header value longer than max-header-value-length" in {
         // the HTTP/1.1 parser reports this with its own, internal exception 
type, which used to escape the
         // decompression stage and fail the whole connection instead of 
answering the one stream


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to