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 64752d44a fix: reject uppercase HTTP/2 field names and values with 
surrounding whitespace (#1343)
64752d44a is described below

commit 64752d44a45cb5d07fa33ff857f37854bab2a45d
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Oct 11 17:23:25 2026 +0100

    fix: reject uppercase HTTP/2 field names and values with surrounding 
whitespace (#1343)
    
    * fix: reject uppercase HTTP/2 field names and values with surrounding 
whitespace
    
    Motivation:
    RFC 9113 8.2.1 makes an HTTP/2 message malformed when a field name contains 
an
    uppercase character or a field value starts or ends with SP or HTAB. Pekko 
HTTP accepted
    both: regular fields are handed to the HTTP/1.1 header-line parser, which 
matches names
    case-insensitively and trims the value, so the message was silently 
normalised instead of
    rejected. Other HTTP/2 implementations (nghttp2, netty for names) reject 
them, so two hops
    can disagree about the same field.
    
    On the client, a field rejected in HeaderDecompression left the response 
with no headers
    and was reported as a missing ':status' rather than as what was wrong.
    
    Modification:
    - HeaderDecompression rejects an uppercase field name and a value with 
leading or
      trailing SP/HTAB, next to the existing NUL/CR/LF check. The messages from 
this stage
      drop the "Malformed request:" prefix, since it decodes responses on the 
client too.
    - ResponseParsing reports a header error from decompression as
      "Malformed response: <reason>".
    - Lowercase the incidental uppercase names ("Foo", "Connection", "TE") in 
RequestParsingSpec
      tests that check other rules, and let the ':authority'/':path' tests that 
pass
      whitespace-only or whitespace-terminated values accept the earlier 
field-check message.
    - Add a ResponseParsingSpec for the client side.
    
    Result:
    Requests with such fields get a 400 for the stream; responses with them 
fail with a
    protocol error naming the field problem. Pekko HTTP's own client and server 
always render
    lowercase names, so traffic between them is unaffected.
    
    Tests:
    - Without the main change the new RequestParsingSpec uppercase/whitespace 
tests and the
      ResponseParsingSpec rejection tests fail
    - sbt http2-tests/test: 393 passed
    - sbt http-core/mimaReportBinaryIssues and scalafmt checks: pass
    
    References:
    None - follow-up to #1341
    
    * scalafmt
    
    * reject every character RFC 9113 8.2.1 excludes from field names, not just 
uppercase
---
 .../impl/engine/http2/client/ResponseParsing.scala |  7 +-
 .../engine/http2/hpack/HeaderCompression.scala     | 14 ++++
 .../engine/http2/hpack/HeaderDecompression.scala   | 15 +++-
 .../impl/engine/http2/RequestParsingSpec.scala     | 48 ++++++++++--
 .../engine/http2/client/ResponseParsingSpec.scala  | 91 ++++++++++++++++++++++
 5 files changed, 162 insertions(+), 13 deletions(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsing.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsing.scala
index 78868b2c6..9e49e182e 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsing.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsing.scala
@@ -118,7 +118,12 @@ private[http2] object ResponseParsing {
             headers += httpHeader)
       }
 
-    rec(subStream.initialHeaders.keyValuePairs)
+    subStream.initialHeaders.headerParseErrorDetails match {
+      // a field HeaderDecompression rejected leaves no headers behind, which 
would otherwise be reported as a
+      // missing ':status' rather than as what was wrong
+      case Some(info) => malformedResponse(info.summary)
+      case None       => rec(subStream.initialHeaders.keyValuePairs)
+    }
   }
 
   private def malformedResponse(msg: String): Nothing =
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
index c86ef6367..8e39f21c0 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/hpack/HeaderCompression.scala
@@ -38,6 +38,20 @@ private[http2] object HeaderCompression extends 
GraphStage[FlowShape[FrameEvent,
   private[http2] def hasIllegalChar(s: String): Boolean =
     s.indexOf('\r') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\u0000') >= 0
 
+  /**
+   * RFC 9113 8.2.1: an HTTP/2 field name must not contain characters in the 
ranges 0x00-0x20, 0x41-0x5a or
+   * 0x7f-0xff, i.e. no control characters, SP, uppercase characters or 
non-ASCII characters. Anything above 0xff
+   * is rejected too, since such a character can only come from a non-ASCII 
octet sequence.
+   */
+  private[http2] def hasInvalidFieldNameChar(s: String): Boolean =
+    s.exists(c => c <= 0x20 || (c >= 'A' && c <= 'Z') || c >= 0x7F)
+
+  /** RFC 9113 8.2.1: an HTTP/2 field value must not start or end with SP or 
HTAB. */
+  private[http2] def hasSurroundingWhitespace(s: String): Boolean =
+    s.nonEmpty && (isWhitespace(s.charAt(0)) || isWhitespace(s.charAt(s.length 
- 1)))
+
+  private def isWhitespace(c: Char): Boolean = c == ' ' || c == '\t'
+
   def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
     new GraphStageLogic(shape) with StageLogging with InHandler with 
OutHandler { logic =>
       setHandlers(eventsIn, eventsOut, this)
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 4cd22a15a..408ae35a8 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
@@ -85,12 +85,19 @@ private[http2] final class 
HeaderDecompression(masterHeaderParser: HttpHeaderPar
             // 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.
+            // The same section makes a name with control, SP, uppercase or 
non-ASCII characters and a value with
+            // leading or trailing whitespace malformed; the HTTP/1.1 parser 
would otherwise match the name
+            // case-insensitively and trim the value.
+            // This stage decodes responses on the client too, so the messages 
do not say "request".
             if (HeaderCompression.hasIllegalChar(name))
-              throw new ParsingException(
-                ErrorInfo("Malformed request: header field name must not 
contain CR, LF or NUL"))
+              throw new ParsingException(ErrorInfo("header field name must not 
contain CR, LF or NUL"))
             if (HeaderCompression.hasIllegalChar(value))
-              throw new ParsingException(
-                ErrorInfo("Malformed request: header field value must not 
contain CR, LF or NUL"))
+              throw new ParsingException(ErrorInfo("header field value must 
not contain CR, LF or NUL"))
+            if (HeaderCompression.hasInvalidFieldNameChar(name))
+              throw new ParsingException(ErrorInfo(
+                "header field name must not contain control, whitespace, 
uppercase or non-ASCII characters"))
+            if (HeaderCompression.hasSurroundingWhitespace(value))
+              throw new ParsingException(ErrorInfo("header field value must 
not start or end with whitespace"))
             if (parsed ne null) {
               headers += name -> parsed
               parsed
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 65067d8c6..1af04d359 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
@@ -109,6 +109,12 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           futureValueEx.getCause.asInstanceOf[Http2ProtocolException]
       }
 
+    "accept a header value with whitespace inside it, or an empty value" in {
+      val request = parseExpectOk(Vector(":method" -> "GET", ":scheme" -> 
"https", ":path" -> "/", "x-a" -> "a b",
+        "x-b" -> ""))
+      (request.headers.map(h => h.lowercaseName -> h.value) should 
contain).allOf("x-a" -> "a b", "x-b" -> "")
+    }
+
     "reject a malformed header field with a bad request rather than accepting 
or failing the connection" 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)] =
@@ -132,6 +138,26 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
         val info = parseExpectError(request("x-a\r\nx-b" -> "v"))
         info.summary should include("header field name must not contain CR, LF 
or NUL")
       }
+      "a header name containing an uppercase character" in {
+        // the HTTP/1.1 parser this is handed to matches names 
case-insensitively, so without the check the
+        // request was accepted as if the name had been lowercase
+        for (name <- Seq("X-A", "x-A", "Content-Type"))
+          parseExpectError(request(name -> "v")).summary should ===(
+            "header field name must not contain control, whitespace, uppercase 
or non-ASCII characters")
+      }
+      "a header name containing a control, whitespace or non-ASCII character" 
in {
+        // RFC 9113 8.2.1 also excludes 0x00-0x20 and 0x7f-0xff from field 
names
+        for (name <- Seq("x a", "x\ta", "x\u0001a", "x-a ", "x\u007fa", 
"x\u0080a", "x\u00ffa", "x\u00e9a"))
+          parseExpectError(request(name -> "v")).summary should ===(
+            "header field name must not contain control, whitespace, uppercase 
or non-ASCII characters")
+      }
+      "a header value with leading or trailing whitespace" in {
+        // the HTTP/1.1 parser this is handed to trims it, so without the 
check the request was accepted with the
+        // value silently changed
+        for (value <- Seq(" v", "v ", "\tv", "v\t"))
+          parseExpectError(request("x-a" -> value)).summary should ===(
+            "header field value must not start or end with whitespace")
+      }
       "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
@@ -182,9 +208,9 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           ":scheme" -> "https",
           ":path" -> "/")
         forAll(pseudoHeaders.indices: Seq[Int]) { (insertPoint: Int) =>
-          // Insert the Foo header so it occurs before at least one 
pseudo-header
+          // Insert the foo header so it occurs before at least one 
pseudo-header
           val (before, after) = pseudoHeaders.splitAt(insertPoint)
-          val modified = before ++ Vector("Foo" -> "bar") ++ after
+          val modified = before ++ Vector("foo" -> "bar") ++ after
           parseExpectProtocolError(modified)
         }
       }
@@ -200,8 +226,8 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           ":method" -> "GET",
           ":scheme" -> "https",
           ":path" -> "/",
-          "Connection" -> "foo",
-          "Foo" -> "bar"))
+          "connection" -> "foo",
+          "foo" -> "bar"))
       }
 
       "not accept TE with other values than 'trailers'" in {
@@ -213,7 +239,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           ":method" -> "GET",
           ":scheme" -> "https",
           ":path" -> "/",
-          "TE" -> "chunked"))
+          "te" -> "chunked"))
 
       }
 
@@ -222,7 +248,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           ":method" -> "GET",
           ":scheme" -> "https",
           ":path" -> "/",
-          "TE" -> "trailers"))
+          "te" -> "trailers"))
       }
 
       // 8.1.2.3.  Request Pseudo-Header Fields
@@ -301,7 +327,10 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
                 ":scheme" -> "https",
                 ":authority" -> authority,
                 ":path" -> "/"))
-            info.summary should include("http2-authority-pseudo-header")
+            // a value that is all whitespace is caught by the RFC 9113 8.2.1 
field check before the URI parser
+            info.summary should
+            (include("http2-authority-pseudo-header").or(
+              include("header field value must not start or end with 
whitespace")))
           }
         }
       }
@@ -412,7 +441,10 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
             "http://localhost/foo";)
           forAll(invalidAbsolutePaths) { (absPath: String) =>
             val info = parsePathExpectError(absPath)
-            info.summary should include("http2-path-pseudo-header")
+            // a value ending in whitespace is caught by the RFC 9113 8.2.1 
field check before the URI parser
+            info.summary should
+            (include("http2-path-pseudo-header").or(
+              include("header field value must not start or end with 
whitespace")))
           }
         }
 
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsingSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsingSpec.scala
new file mode 100644
index 000000000..87173e9be
--- /dev/null
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/client/ResponseParsingSpec.scala
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.http.impl.engine.http2.client
+
+import scala.util.Try
+
+import org.apache.pekko
+import pekko.http.impl.engine.http2.{ HPackEncodingSupport, Http2SubStream }
+import pekko.http.impl.engine.http2.FrameEvent._
+import pekko.http.impl.engine.http2.Http2Compliance.Http2ProtocolException
+import pekko.http.impl.engine.http2.hpack.HeaderDecompression
+import pekko.http.impl.engine.parsing.HttpHeaderParser
+import pekko.http.impl.util.PekkoSpecWithMaterializer
+import pekko.http.scaladsl.model._
+import pekko.http.scaladsl.settings.ClientConnectionSettings
+import pekko.stream.Attributes
+import pekko.stream.scaladsl.{ Sink, Source }
+import pekko.util.{ ByteString, OptionVal }
+
+class ResponseParsingSpec extends PekkoSpecWithMaterializer {
+  "ResponseParsing" should {
+
+    def parse(keyValuePairs: Seq[(String, String)]): Try[HttpResponse] = {
+      val settings = ClientConnectionSettings(system)
+      val headerParser = HttpHeaderParser(settings.parserSettings, log)
+      val encoder = new HPackEncodingSupport {}
+      val frame = HeadersFrame(1, endStream = true, endHeaders = true, 
encoder.encodeHeaderPairs(keyValuePairs), None)
+
+      Source.single(frame)
+        .via(new HeaderDecompression(headerParser, settings.parserSettings, 
settings.http2Settings.maxHeaderListSize))
+        .map { // emulate demux
+          case headers: ParsedHeadersFrame =>
+            Http2SubStream(
+              initialHeaders = headers,
+              trailingHeaders = OptionVal.None,
+              data = Right(Source.empty[ByteString]),
+              correlationAttributes = Map.empty)
+        }
+        .map(subStream =>
+          Try(ResponseParsing.parseResponse(headerParser, 
settings.parserSettings, Attributes())(subStream)))
+        .runWith(Sink.head)
+        .futureValue
+    }
+
+    def malformedResponseMessage(keyValuePairs: Seq[(String, String)]): String 
=
+      parse(keyValuePairs).failed.get match {
+        case ex: Http2ProtocolException => ex.getMessage
+        case other                      => fail(s"Expected an 
Http2ProtocolException but got $other")
+      }
+
+    "parse a response with lowercase field names" in {
+      val response = parse(Seq(":status" -> "200", "x-a" -> "v")).get
+      response.status should ===(StatusCodes.OK)
+      response.headers.map(h => h.lowercaseName -> h.value) should 
contain("x-a" -> "v")
+    }
+
+    // RFC 9113 8.2.1: a field name must not contain control, SP, uppercase or 
non-ASCII characters and a value must not start or end with
+    // whitespace. HeaderDecompression rejects either, and the failure has to 
name what was wrong rather than surface as
+    // the ':status' missing from the headers it dropped.
+    "reject a field name containing an uppercase character" in {
+      malformedResponseMessage(Seq(":status" -> "200", "X-A" -> "v")) should 
===(
+        "Malformed response: header field name must not contain control, 
whitespace, uppercase or non-ASCII characters")
+    }
+
+    "reject a field value with leading or trailing whitespace" in {
+      for (value <- Seq(" v", "v ", "\tv", "v\t"))
+        malformedResponseMessage(Seq(":status" -> "200", "x-a" -> value)) 
should ===(
+          "Malformed response: header field value must not start or end with 
whitespace")
+    }
+
+    "reject a field value containing CR, LF or NUL" in {
+      malformedResponseMessage(Seq(":status" -> "200", "x-a" -> "a\r\nb")) 
should ===(
+        "Malformed response: header field value must not contain CR, LF or 
NUL")
+    }
+  }
+}


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

Reply via email to