This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch 1.4.x
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/1.4.x by this push:
     new b396b633b http/2: reject a header field carrying CR, LF or NUL (#1297) 
(1.4.x) (#1330)
b396b633b is described below

commit b396b633bed9d9e1537cc3e97149092de41ea062
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Oct 7 15:27:26 2026 +0100

    http/2: reject a header field carrying CR, LF or NUL (#1297) (1.4.x) (#1330)
    
    Motivation:
    A regular HTTP/2 header field whose value contains CR LF was silently
    accepted with the value truncated: `RequestParsing.parseHeaderPair` reuses
    the HTTP/1.1 line parser by building `name + ": " + value + "\r\nx"`, so the
    parser stops at the first CRLF it meets, which is now the peer's. RFC 9113
    8.2.1 says a field name or value carrying NUL, CR or LF makes the message
    malformed.
    
    Modification:
    Adapted backport of 40b07a21d (#1297). Check every field in the HPACK
    listener before it is dispatched on its name and fail with an
    Http2ProtocolException, which `HeaderDecompression` turns into
    GOAWAY(PROTOCOL_ERROR) - the way 1.4.x already treats other malformed
    request headers. On main the same check answers the stream with a 400 and
    keeps the connection, but that relies on #961 (ParsedHeadersFrame error
    info, RequestErrorFlow) and #1251, which are not on 1.4.x.
    
    Result:
    A request whose header field name or value contains CR, LF or NUL is no
    longer accepted with a truncated value; the connection is closed with
    GOAWAY(PROTOCOL_ERROR) and the request never reaches the handler.
---
 .../engine/http2/hpack/HeaderDecompression.scala   | 11 +++++++++
 .../http/impl/engine/http2/Http2ServerSpec.scala   | 21 +++++++++++++++++
 .../impl/engine/http2/RequestParsingSpec.scala     | 27 ++++++++++++++++++++++
 3 files changed, 59 insertions(+)

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 fc7aafb36..ab4d211af 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
@@ -74,6 +74,14 @@ private[http2] final class 
HeaderDecompression(masterHeaderParser: HttpHeaderPar
         val headers = new VectorBuilder[(String, AnyRef)]
         object Receiver extends HeaderListener {
           def addHeader(name: String, value: String, parsed: AnyRef, 
sensitive: Boolean): AnyRef = {
+            // RFC 9113 8.2.1: a field name or value carrying a NUL, CR or LF 
makes the message malformed. Check it
+            // here, before the field is dispatched on its name: a regular 
field goes through the HTTP/1.1 line
+            // parser, which reads up to the first CRLF it finds and would 
silently accept the value truncated
+            // there. Neither the name nor the value is echoed, since either 
may be what is malformed.
+            if (HeaderCompression.hasIllegalChar(name))
+              throw new Http2ProtocolException("Malformed request: header 
field name must not contain CR, LF or NUL")
+            if (HeaderCompression.hasIllegalChar(value))
+              throw new Http2ProtocolException("Malformed request: header 
field value must not contain CR, LF or NUL")
             if (parsed ne null) {
               headers += name -> parsed
               parsed
@@ -110,6 +118,9 @@ private[http2] final class 
HeaderDecompression(masterHeaderParser: HttpHeaderPar
           if (truncated) headerListSizeExceeded(streamId)
           else push(eventsOut, ParsedHeadersFrame(streamId, endStream, 
headers.result(), prioInfo))
         } catch {
+          case ex: Http2ProtocolException =>
+            // a malformed header field, rejected by the listener above: fail 
with GOAWAY(PROTOCOL_ERROR)
+            failStage(ex)
           case _: IOException =>
             // this is signalled by the decoder when it failed, we want to 
react to this by rendering a GOAWAY frame
             fail(eventsOut,
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala
index 534e0ff63..027c80731 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerSpec.scala
@@ -98,6 +98,27 @@ class Http2ServerSpec extends Http2SpecWithMaterializer("""
         val (_, errorCode) = network.expectGOAWAY(0) // since we have not 
processed any stream
         errorCode should ===(ErrorCode.COMPRESSION_ERROR)
       })
+      "GOAWAY when a header field carries CR, LF or NUL" should {
+        abstract class MalformedHeaderSetup extends TestSetup with 
RequestResponseProbes {
+          def expectProtocolError(headerPairs: Seq[(String, String)]): Unit = {
+            user.requestIn.request(1)
+            network.sendHEADERS(1, endStream = true, endHeaders = true, 
network.encodeHeaderPairs(headerPairs))
+            val (_, errorCode) = network.expectGOAWAY(0)
+            errorCode should ===(ErrorCode.PROTOCOL_ERROR)
+            // the request, with or without its value truncated, never reaches 
the handler
+            user.requestIn.expectNoMessage(100.millis)
+          }
+          def request(extra: (String, String)*): Seq[(String, String)] =
+            Seq(":method" -> "GET", ":scheme" -> "https", ":path" -> "/", 
":authority" -> "www.example.com") ++ extra
+        }
+
+        "for a value containing CR LF".inAssertAllStagesStopped(new 
MalformedHeaderSetup {
+          expectProtocolError(request("x-a" -> "foo\r\nx-b: bar"))
+        })
+        "for a value containing NUL".inAssertAllStagesStopped(new 
MalformedHeaderSetup {
+          expectProtocolError(request("x-a" -> "foo\u0000bar"))
+        })
+      }
       "GOAWAY when second request on different stream has invalid headers 
frame".inAssertAllStagesStopped(
         new SimpleRequestResponseRoundtripSetup {
           requestResponseRoundtrip(
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 57579b500..a97523f31 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
@@ -23,6 +23,7 @@ import pekko.stream.scaladsl.{ Sink, Source }
 import pekko.util.{ ByteString, OptionVal }
 import org.scalatest.{ Inside, Inspectors }
 import FrameEvent._
+import pekko.http.impl.engine.http2.Http2Compliance.Http2ProtocolException
 import pekko.http.impl.engine.http2.hpack.HeaderDecompression
 import pekko.http.impl.engine.server.HttpAttributes
 import pekko.http.impl.util.PekkoSpecWithMaterializer
@@ -75,6 +76,32 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       thrown
     }
 
+    "reject a malformed header field" should {
+      // RFC 9113 8.2.1: a field name or value carrying NUL, CR or LF makes 
the message malformed
+      def request(extra: (String, String)*): Vector[(String, String)] =
+        Vector(":method" -> "GET", ":scheme" -> "https", ":path" -> "/") ++ 
extra
+
+      "a header value containing CR LF" in {
+        // the HTTP/1.1 line parser this is handed to stops at the first CRLF 
it finds, so without the check the
+        // request was accepted with the value silently truncated to `foo`
+        val thrown = shouldThrowMalformedRequest(parse(request("x-a" -> 
"foo\r\nx-b: bar")))
+        thrown shouldBe an[Http2ProtocolException]
+        thrown.getMessage should include("header field value must not contain 
CR, LF or NUL")
+      }
+      "a header value containing a bare LF" in {
+        val thrown = shouldThrowMalformedRequest(parse(request("x-a" -> 
"foo\nbar")))
+        thrown.getMessage should include("header field value must not contain 
CR, LF or NUL")
+      }
+      "a header value containing NUL" in {
+        val thrown = shouldThrowMalformedRequest(parse(request("x-a" -> 
"foo\u0000bar")))
+        thrown.getMessage should include("header field value must not contain 
CR, LF or NUL")
+      }
+      "a header name containing CR LF" in {
+        val thrown = shouldThrowMalformedRequest(parse(request("x-a\r\nx-b" -> 
"v")))
+        thrown.getMessage should include("header field name must not contain 
CR, LF or NUL")
+      }
+    }
+
     "follow RFC7540" should {
 
       // 8.1.2.1.  Pseudo-Header Fields


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

Reply via email to