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

fanningpj 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 31b3e2753 Invalid HTTP/2 request headers return 400 Bad Request 
instead of GOAWAY (#961)
31b3e2753 is described below

commit 31b3e27537d27bd497c7f6f392c1085d679fb6d2
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Mar 8 12:22:30 2026 +0100

    Invalid HTTP/2 request headers return 400 Bad Request instead of GOAWAY 
(#961)
    
    * Initial plan
    
    * Port akka-http PR #4227: fix invalid HTTP2 request headers leading to bad 
request responses
    
    Co-authored-by: pjfanning <[email protected]>
    
    * small changes
    
    * Fix post-merge issues: remove .right deprecation, remove duplicate 
import, remove unused @nowarn
    
    Co-authored-by: pjfanning <[email protected]>
    
    * scalafmt
    
    * Update RequestParsingSpec.scala
    
    * Add test for invalid percent-encoding in HTTP/2 path (pekko-http issue 
#59)
    
    Co-authored-by: pjfanning <[email protected]>
    
    * Revert RFC-violation errors to protocolError to fix h2spec acceptance 
tests, keep %_D as BadRequest
    
    Co-authored-by: pjfanning <[email protected]>
    
    * scalafmt
    
    ---------
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    Co-authored-by: pjfanning <[email protected]>
---
 .../bad-header-http2-response.backwards.excludes   |  23 ++
 .../pekko/http/impl/engine/http2/FrameEvent.scala  |   6 +-
 .../pekko/http/impl/engine/http2/FrameLogger.scala |   2 +-
 .../http/impl/engine/http2/Http2Blueprint.scala    |  16 +-
 .../impl/engine/http2/Http2StreamHandling.scala    |   4 +-
 .../impl/engine/http2/HttpMessageRendering.scala   |   4 +-
 .../http/impl/engine/http2/RequestErrorFlow.scala  |  74 +++++++
 .../http/impl/engine/http2/RequestParsing.scala    |  61 ++++--
 .../impl/engine/http2/client/ResponseParsing.scala |  16 +-
 .../engine/http2/hpack/HeaderCompression.scala     |   2 +-
 .../engine/http2/hpack/HeaderDecompression.scala   |  17 +-
 .../engine/http2/hpack/Http2HeaderParsing.scala    |  11 +-
 .../impl/engine/http2/Http2ClientServerSpec.scala  |  12 ++
 .../impl/engine/http2/Http2ServerDemuxSpec.scala   |   2 +-
 .../impl/engine/http2/RequestParsingSpec.scala     | 240 ++++++++++++---------
 15 files changed, 343 insertions(+), 147 deletions(-)

diff --git 
a/http-core/src/main/mima-filters/1.4.x.backwards.excludes/bad-header-http2-response.backwards.excludes
 
b/http-core/src/main/mima-filters/1.4.x.backwards.excludes/bad-header-http2-response.backwards.excludes
new file mode 100644
index 000000000..fab687cc9
--- /dev/null
+++ 
b/http-core/src/main/mima-filters/1.4.x.backwards.excludes/bad-header-http2-response.backwards.excludes
@@ -0,0 +1,23 @@
+# 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.
+
+# Invalid HTTP/2 request headers return 400 Bad Request instead of GOAWAY
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.impl.engine.http2.FrameEvent#ParsedHeadersFrame.copy")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.impl.engine.http2.FrameEvent#ParsedHeadersFrame.this")
+ProblemFilters.exclude[MissingTypesProblem]("org.apache.pekko.http.impl.engine.http2.FrameEvent$ParsedHeadersFrame$")
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.impl.engine.http2.FrameEvent#ParsedHeadersFrame.apply")
+ProblemFilters.exclude[IncompatibleSignatureProblem]("org.apache.pekko.http.impl.engine.http2.FrameEvent#ParsedHeadersFrame.unapply")
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameEvent.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameEvent.scala
index 6c93916b4..9888ee0ea 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameEvent.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameEvent.scala
@@ -18,6 +18,7 @@ import pekko.annotation.InternalApi
 import pekko.http.impl.engine.http2.Http2Protocol.ErrorCode
 import pekko.http.impl.engine.http2.Http2Protocol.FrameType
 import pekko.http.impl.engine.http2.Http2Protocol.SettingIdentifier
+import pekko.http.scaladsl.model.ErrorInfo
 import pekko.util.ByteString
 
 import scala.collection.immutable
@@ -112,11 +113,14 @@ private[http] object FrameEvent {
   /**
    * Convenience (logical) representation of a parsed HEADERS frame with zero, 
one or
    * many CONTINUATIONS Frames into a single, decompressed object.
+   *
+   * @param headerParseErrorDetails Only used server side, passes header 
errors from decompression into error response logic
    */
   final case class ParsedHeadersFrame(
       streamId: Int,
       endStream: Boolean,
       keyValuePairs: Seq[(String, AnyRef)],
-      priorityInfo: Option[PriorityFrame]) extends StreamFrameEvent
+      priorityInfo: Option[PriorityFrame],
+      headerParseErrorDetails: Option[ErrorInfo]) extends StreamFrameEvent
 
 }
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameLogger.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameLogger.scala
index 418cb3ac5..00caa0485 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameLogger.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/FrameLogger.scala
@@ -79,7 +79,7 @@ private[http2] object FrameLogger {
         case GoAwayFrame(lastStreamId, errorCode, debug) =>
           LogEntry(0, "GOAY", s"lastStreamId = $lastStreamId, errorCode = 
$errorCode, debug = ${debug.utf8String}")
 
-        case ParsedHeadersFrame(streamId, endStream, kvPairs, prio) =>
+        case ParsedHeadersFrame(streamId, endStream, kvPairs, prio, _) =>
           val prioInfo = if (prio.isDefined) display(entryForFrame(prio.get)) 
+ " " else ""
           val kvInfo = kvPairs.map {
             case (key, value) => s"$key -> $value"
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala
index 3b14c0b86..576faa00d 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2Blueprint.scala
@@ -274,7 +274,7 @@ private[http] object Http2Blueprint {
    *
    * To make use of parallelism requests and responses need to be associated 
(other than by ordering), suggestion
    * is to add a special (virtual) header containing the streamId (or any 
other kind of token) is added to the HttRequest
-   * that must be reproduced in an HttpResponse. This can be done 
automatically for the `bind`` API but for
+   * that must be reproduced in an HttpResponse. This can be done 
automatically for the `bind` API but for
    * `bindFlow` the user needs to take of this manually.
    */
   def httpLayer(settings: ServerSettings, log: LoggingAdapter, 
dateHeaderRendering: DateHeaderRendering)
@@ -284,12 +284,14 @@ private[http] object Http2Blueprint {
     // HttpHeaderParser is not thread safe and should not be called 
concurrently,
     // the internal trie, however, has built-in protection and will do 
copy-on-write
     val masterHttpHeaderParser = HttpHeaderParser(parserSettings, log)
-    BidiFlow.fromFlows(
-      Flow[HttpResponse].map(new ResponseRendering(settings, log, 
dateHeaderRendering)),
-      Flow[Http2SubStream].via(StreamUtils.statefulAttrsMap { attrs =>
-        val headerParser = masterHttpHeaderParser.createShallowCopy()
-        RequestParsing.parseRequest(headerParser, settings, attrs)
-      }))
+    RequestErrorFlow().atop(
+      BidiFlow.fromFlows(
+        Flow[HttpResponse].map(new ResponseRendering(settings, log, 
dateHeaderRendering)),
+        Flow[Http2SubStream].via(StreamUtils.statefulAttrsMap { attrs =>
+          val headerParser = masterHttpHeaderParser.createShallowCopy()
+          RequestParsing.parseRequest(headerParser, settings, attrs)
+        }))
+    )
   }
 
   /**
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2StreamHandling.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2StreamHandling.scala
index a09f4ef64..e17ca4dc3 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2StreamHandling.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2StreamHandling.scala
@@ -299,7 +299,7 @@ private[http2] trait Http2StreamHandling extends 
GraphStageLogic with LogHelper
         nextStateStream: IncomingStreamBuffer => StreamState,
         correlationAttributes: Map[AttributeKey[_], _] = Map.empty): 
StreamState =
       event match {
-        case frame @ ParsedHeadersFrame(streamId, endStream, _, _) =>
+        case frame @ ParsedHeadersFrame(streamId, endStream, _, _, _) =>
           if (endStream) {
             dispatchSubstream(frame, Left(ByteString.empty), 
correlationAttributes)
             nextStateEmpty
@@ -833,7 +833,7 @@ private[http2] trait Http2StreamHandling extends 
GraphStageLogic with LogHelper
               "Found both an attribute with trailing headers, and headers in 
the `LastChunk`. This is not supported.")
           trailer = OptionVal.Some(ParsedHeadersFrame(streamId, endStream = 
true,
             HttpMessageRendering.renderHeaders(headers, log, isServer, 
shouldRenderAutoHeaders = false,
-              dateHeaderRendering = DateHeaderRendering.Unavailable), None))
+              dateHeaderRendering = DateHeaderRendering.Unavailable), None, 
None))
       }
 
       maybePull()
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala
index e434b857e..d2dc3028e 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala
@@ -94,11 +94,11 @@ private[http2] sealed abstract class MessageRendering[R <: 
HttpMessage] extends
       shouldRenderAutoHeaders = true, dateHeaderRendering)
 
     val streamId = nextStreamId(r)
-    val headersFrame = ParsedHeadersFrame(streamId, endStream = 
r.entity.isKnownEmpty, headerPairs.result(), None)
+    val headersFrame = ParsedHeadersFrame(streamId, endStream = 
r.entity.isKnownEmpty, headerPairs.result(), None, None)
     val trailingHeadersFrame =
       r.attribute(AttributeKeys.trailer) match {
         case Some(trailer) if trailer.headers.nonEmpty =>
-          OptionVal.Some(ParsedHeadersFrame(streamId, endStream = true, 
trailer.headers, None))
+          OptionVal.Some(ParsedHeadersFrame(streamId, endStream = true, 
trailer.headers, None, None))
         case _ => OptionVal.None
       }
 
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestErrorFlow.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestErrorFlow.scala
new file mode 100644
index 000000000..c37944992
--- /dev/null
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestErrorFlow.scala
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2009-2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.http.impl.engine.http2
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.InternalApi
+import pekko.http.impl.engine.http2.RequestParsing.ParseRequestResult
+import pekko.http.scaladsl.model.{ HttpRequest, HttpResponse, StatusCodes }
+import pekko.stream.{ Attributes, BidiShape, Inlet, Outlet }
+import pekko.stream.scaladsl.BidiFlow
+import pekko.stream.stage.{ GraphStage, GraphStageLogic, InHandler, OutHandler 
}
+
+/**
+ * INTERNAL API
+ */
+@InternalApi
+private[http2] object RequestErrorFlow {
+
+  private val _bidiFlow = BidiFlow.fromGraph(new RequestErrorFlow)
+  def apply(): BidiFlow[HttpResponse, HttpResponse, ParseRequestResult, 
HttpRequest, NotUsed] = _bidiFlow
+
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalApi
+private[http2] final class RequestErrorFlow
+    extends GraphStage[BidiShape[HttpResponse, HttpResponse, 
ParseRequestResult, HttpRequest]] {
+
+  val requestIn = Inlet[ParseRequestResult]("requestIn")
+  val requestOut = Outlet[HttpRequest]("requestOut")
+  val responseIn = Inlet[HttpResponse]("responseIn")
+  val responseOut = Outlet[HttpResponse]("responseOut")
+
+  override val shape: BidiShape[HttpResponse, HttpResponse, 
ParseRequestResult, HttpRequest] =
+    BidiShape(responseIn, responseOut, requestIn, requestOut)
+
+  override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = 
new GraphStageLogic(shape) {
+    setHandlers(requestIn, requestOut,
+      new InHandler with OutHandler {
+        override def onPush(): Unit = {
+          grab(requestIn) match {
+            case RequestParsing.OkRequest(request) => push(requestOut, request)
+            case notOk: RequestParsing.BadRequest  =>
+              emit(responseOut,
+                HttpResponse(StatusCodes.BadRequest, entity = 
notOk.info.summary).addAttribute(Http2.streamId,
+                  notOk.streamId))
+              pull(requestIn)
+          }
+        }
+
+        override def onPull(): Unit = pull(requestIn)
+      })
+    setHandlers(responseIn, responseOut,
+      new InHandler with OutHandler {
+        override def onPush(): Unit = push(responseOut, grab(responseIn))
+        override def onPull(): Unit = if (!hasBeenPulled(responseIn)) 
pull(responseIn)
+      })
+
+  }
+}
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestParsing.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestParsing.scala
index e5f557ee7..8b3f148b7 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestParsing.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/RequestParsing.scala
@@ -16,7 +16,6 @@ package org.apache.pekko.http.impl.engine.http2
 import javax.net.ssl.SSLSession
 import org.apache.pekko
 import pekko.annotation.InternalApi
-import pekko.http.impl.engine.http2.Http2Compliance.Http2ProtocolException
 import pekko.http.impl.engine.parsing.HttpHeaderParser
 import pekko.http.impl.engine.server.HttpAttributes
 import pekko.http.scaladsl.model
@@ -29,6 +28,7 @@ import pekko.util.OptionVal
 
 import scala.annotation.tailrec
 import scala.collection.immutable.VectorBuilder
+import scala.util.control.NoStackTrace
 
 /**
  * INTERNAL API
@@ -36,8 +36,12 @@ import scala.collection.immutable.VectorBuilder
 @InternalApi
 private[http2] object RequestParsing {
 
+  sealed trait ParseRequestResult
+  final case class OkRequest(request: HttpRequest) extends ParseRequestResult
+  final case class BadRequest(info: ErrorInfo, streamId: Int) extends 
ParseRequestResult
+
   def parseRequest(httpHeaderParser: HttpHeaderParser, serverSettings: 
ServerSettings, streamAttributes: Attributes)
-      : Http2SubStream => HttpRequest = {
+      : Http2SubStream => ParseRequestResult = {
 
     val remoteAddressAttribute: Option[RemoteAddress] =
       if (serverSettings.remoteAddressAttribute) {
@@ -151,19 +155,19 @@ private[http2] object RequestParsing {
                 rec(incomingHeaders, offset + 1, method, scheme, authority, 
pathAndRawQuery,
                   OptionVal.Some(ContentType.get(value)), contentLength, 
cookies, true, headers)
               else
-                malformedRequest("HTTP message must not contain more than one 
content-type header")
+                parseError("HTTP message must not contain more than one 
content-type header", "content-type")
 
             case ":status" =>
-              malformedRequest("Pseudo-header ':status' is for responses only; 
it cannot appear in a request")
+              protocolError("Pseudo-header ':status' is for responses only; it 
cannot appear in a request")
 
             case "content-length" =>
               if (contentLength == -1) {
                 val contentLengthValue = ContentLength.get(value).toLong
                 if (contentLengthValue < 0)
-                  malformedRequest("HTTP message must not contain a negative 
content-length header")
+                  parseError("HTTP message must not contain a negative 
content-length header", "content-length")
                 rec(incomingHeaders, offset + 1, method, scheme, authority, 
pathAndRawQuery, contentType,
                   contentLengthValue, cookies, true, headers)
-              } else malformedRequest("HTTP message must not contain more than 
one content-length header")
+              } else parseError("HTTP message must not contain more than one 
content-length header", "content-length")
 
             case "cookie" =>
               // Compress cookie headers as described here 
https://tools.ietf.org/html/rfc7540#section-8.1.2.5
@@ -182,11 +186,21 @@ private[http2] object RequestParsing {
           }
         }
 
-      val incomingHeaders = subStream.initialHeaders.keyValuePairs.toIndexedSeq
-      if (incomingHeaders.size > serverSettings.parserSettings.maxHeaderCount)
-        malformedRequest(
-          s"HTTP message contains more than the configured limit of 
${serverSettings.parserSettings.maxHeaderCount} headers")
-      else rec(incomingHeaders, 0)
+      try {
+        subStream.initialHeaders.headerParseErrorDetails match {
+          case Some(details) =>
+            // header errors already found in decompression
+            BadRequest(details, subStream.streamId)
+          case None =>
+            val incomingHeaders = 
subStream.initialHeaders.keyValuePairs.toIndexedSeq
+            if (incomingHeaders.size > 
serverSettings.parserSettings.maxHeaderCount)
+              parseError(
+                s"HTTP message contains more than the configured limit of 
${serverSettings.parserSettings.maxHeaderCount} headers")
+            else OkRequest(rec(incomingHeaders, 0))
+        }
+      } catch {
+        case bre: ParsingException => BadRequest(bre.info, subStream.streamId)
+      }
     }
   }
 
@@ -201,25 +215,34 @@ private[http2] object RequestParsing {
   }
 
   private[http2] def checkRequiredPseudoHeader(name: String, value: AnyRef): 
Unit =
-    if (value eq null) malformedRequest(s"Mandatory pseudo-header '$name' 
missing")
+    if (value eq null) protocolError(s"Mandatory pseudo-header '$name' 
missing")
   private[http2] def checkUniquePseudoHeader(name: String, value: AnyRef): 
Unit =
-    if (value ne null) malformedRequest(s"Pseudo-header '$name' must not occur 
more than once")
+    if (value ne null) protocolError(s"Pseudo-header '$name' must not occur 
more than once")
   private[http2] def checkNoRegularHeadersBeforePseudoHeader(name: String, 
seenRegularHeader: Boolean): Unit =
-    if (seenRegularHeader) malformedRequest(s"Pseudo-header field '$name' must 
not appear after a regular header")
-  private[http2] def malformedRequest(msg: String): Nothing =
-    throw new Http2ProtocolException(s"Malformed request: $msg")
+    if (seenRegularHeader) protocolError(s"Pseudo-header field '$name' must 
not appear after a regular header")
   private[http2] def validateHeader(httpHeader: HttpHeader) = 
httpHeader.lowercaseName match {
     case "connection" =>
       // https://tools.ietf.org/html/rfc7540#section-8.1.2.2
-      malformedRequest("Header 'Connection' must not be used with HTTP/2")
+      protocolError("Header 'Connection' must not be used with HTTP/2")
     case "transfer-encoding" =>
       // https://tools.ietf.org/html/rfc7540#section-8.1.2.2
-      malformedRequest("Header 'Transfer-Encoding' must not be used with 
HTTP/2")
+      protocolError("Header 'Transfer-Encoding' must not be used with HTTP/2")
     case "te" =>
       // https://tools.ietf.org/html/rfc7540#section-8.1.2.2
       if (httpHeader.value.compareToIgnoreCase("trailers") != 0)
-        malformedRequest(s"Header 'TE' must not contain value other than 
'trailers', value was '${httpHeader.value}")
+        protocolError(s"Header 'TE' must not contain value other than 
'trailers', value was '${httpHeader.value}")
     case _ => // ok
   }
 
+  // parse errors lead to BadRequest response while Protocol
+  private[http2] def protocolError(summary: String): Nothing =
+    throw new Http2Compliance.Http2ProtocolException(s"Malformed request: 
$summary")
+
+  private[http2] def parseError(summary: String, headerName: String): Nothing =
+    throw new ParsingException(ErrorInfo(s"Malformed request: 
$summary").withErrorHeaderName(headerName))
+      with NoStackTrace
+
+  private def parseError(summary: String): Nothing =
+    throw new ParsingException(ErrorInfo(s"Malformed request: $summary")) with 
NoStackTrace
+
 }
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 80b0545e1..78868b2c6 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
@@ -16,6 +16,7 @@ package client
 
 import org.apache.pekko
 import pekko.annotation.InternalApi
+import pekko.http.impl.engine.http2.Http2Compliance.Http2ProtocolException
 import pekko.http.impl.engine.http2.RequestParsing._
 import pekko.http.impl.engine.parsing.HttpHeaderParser
 import pekko.http.impl.engine.server.HttpAttributes
@@ -85,26 +86,26 @@ private[http2] object ResponseParsing {
             rec(remainingHeaders.tail, status, 
OptionVal.Some(contentTypeValue), contentLength, seenRegularHeader,
               headers)
           else
-            malformedRequest("HTTP message must not contain more than one 
content-type header")
+            malformedResponse("HTTP message must not contain more than one 
content-type header")
 
         case ("content-type", ct: String) =>
           if (contentType.isEmpty) {
             val contentTypeValue =
-              ContentType.parse(ct).getOrElse(malformedRequest(s"Invalid 
content-type: '$ct'"))
+              ContentType.parse(ct).getOrElse(malformedResponse(s"Invalid 
content-type: '$ct'"))
             rec(remainingHeaders.tail, status, 
OptionVal.Some(contentTypeValue), contentLength, seenRegularHeader,
               headers)
-          } else malformedRequest("HTTP message must not contain more than one 
content-type header")
+          } else malformedResponse("HTTP message must not contain more than 
one content-type header")
 
         case ("content-length", length: String) =>
           if (contentLength == -1) {
             val contentLengthValue = length.toLong
             if (contentLengthValue < 0)
-              malformedRequest("HTTP message must not contain a negative 
content-length header")
+              malformedResponse("HTTP message must not contain a negative 
content-length header")
             rec(remainingHeaders.tail, status, contentType, 
contentLengthValue, seenRegularHeader, headers)
-          } else malformedRequest("HTTP message must not contain more than one 
content-length header")
+          } else malformedResponse("HTTP message must not contain more than 
one content-length header")
 
         case (name, _) if name.startsWith(':') =>
-          malformedRequest(s"Unexpected pseudo-header '$name' in response")
+          malformedResponse(s"Unexpected pseudo-header '$name' in response")
 
         case (_, httpHeader: HttpHeader) =>
           rec(remainingHeaders.tail, status, contentType, contentLength, 
seenRegularHeader = true,
@@ -119,4 +120,7 @@ private[http2] object ResponseParsing {
 
     rec(subStream.initialHeaders.keyValuePairs)
   }
+
+  private def malformedResponse(msg: String): Nothing =
+    throw new Http2ProtocolException(s"Malformed response: $msg")
 }
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 2738a8f09..60fec77d8 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
@@ -48,7 +48,7 @@ private[http2] object HeaderCompression extends 
GraphStage[FlowShape[FrameEvent,
         case ack @ SettingsAckFrame(s) =>
           applySettings(s)
           push(eventsOut, ack)
-        case ParsedHeadersFrame(streamId, endStream, kvs, prioInfo) =>
+        case ParsedHeadersFrame(streamId, endStream, kvs, prioInfo, _) =>
           // When ending the stream without any payload, use a DATA frame 
rather than
           // a HEADERS frame to work around 
https://github.com/golang/go/issues/47851.
           if (endStream && kvs.isEmpty) push(eventsOut, DataFrame(streamId, 
endStream, ByteString.empty))
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 6ed39670a..6d992e36b 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
@@ -21,6 +21,7 @@ import pekko.http.impl.engine.http2.Http2Protocol.ErrorCode
 import pekko.http.impl.engine.http2.RequestParsing.parseHeaderPair
 import pekko.http.impl.engine.http2._
 import pekko.http.impl.engine.parsing.HttpHeaderParser
+import pekko.http.scaladsl.model.ParsingException
 import pekko.http.scaladsl.settings.ParserSettings
 import pekko.http.shaded.com.twitter.hpack.HeaderListener
 import pekko.stream._
@@ -75,9 +76,12 @@ private[http2] final class 
HeaderDecompression(masterHeaderParser: HttpHeaderPar
               }
 
               name match {
-                case "content-type"   => handle(ContentType.parse(name, value, 
parserSettings))
-                case ":authority"     => handle(Authority.parse(name, value, 
parserSettings))
-                case ":path"          => handle(PathAndQuery.parse(name, 
value, parserSettings))
+                case "content-type" => handle(ContentType.parse(name, value, 
parserSettings))
+                case ":authority"   => handle(Authority.parse(name, value, 
parserSettings))
+                case ":path"        =>
+                  if (value.isEmpty)
+                    throw new Http2ProtocolException("Malformed request: 
':path' must not be empty")
+                  handle(PathAndQuery.parse(name, value, parserSettings))
                 case ":method"        => handle(Method.parse(name, value, 
parserSettings))
                 case ":scheme"        => handle(Scheme.parse(name, value, 
parserSettings))
                 case "content-length" => handle(ContentLength.parse(name, 
value, parserSettings))
@@ -97,9 +101,12 @@ private[http2] final class 
HeaderDecompression(masterHeaderParser: HttpHeaderPar
           decoder.decode(stream, Receiver) // only compact ByteString supports 
InputStream with mark/reset
           decoder.endHeaderBlock() // TODO: do we have to check the result 
here?
 
-          push(eventsOut, ParsedHeadersFrame(streamId, endStream, 
headers.result(), prioInfo))
+          push(eventsOut, ParsedHeadersFrame(streamId, endStream, 
headers.result(), prioInfo, None))
         } catch {
-          case ex: IOException =>
+          case ex: ParsingException =>
+            // push details further and let RequestErrorFlow handle responding 
with bad request
+            push(eventsOut, ParsedHeadersFrame(streamId, endStream, Seq.empty, 
prioInfo, Some(ex.info)))
+          case _: IOException =>
             // this is signalled by the decoder when it failed, we want to 
react to this by rendering a GOAWAY frame
             fail(eventsOut,
               new 
Http2Compliance.Http2ProtocolException(ErrorCode.COMPRESSION_ERROR, 
"Decompression failed."))
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 ae135cceb..7b2066065 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
@@ -15,7 +15,8 @@ package org.apache.pekko.http.impl.engine.http2.hpack
 
 import org.apache.pekko
 import pekko.annotation.InternalApi
-import pekko.http.impl.engine.http2.RequestParsing.malformedRequest
+import pekko.http.impl.engine.http2.RequestParsing
+import pekko.http.impl.engine.http2.RequestParsing.parseError
 import pekko.http.scaladsl.model
 import pekko.http.scaladsl.model.HttpHeader.ParsingResult
 import model.{ HttpHeader, HttpMethod, HttpMethods, IllegalUriException, 
ParsingException, StatusCode, Uri }
@@ -36,7 +37,7 @@ private[pekko] object Http2HeaderParsing {
     override def parse(name: String, value: String, parserSettings: 
ParserSettings): HttpMethod =
       HttpMethods.getForKey(value)
         .orElse(parserSettings.customMethods(value))
-        .getOrElse(malformedRequest(s"Unknown HTTP method: '$value'"))
+        .getOrElse(RequestParsing.parseError(s"Unknown HTTP method: '$value'", 
":method"))
   }
   object PathAndQuery extends HeaderParser[(Uri.Path, 
Option[String])](":path") {
     override def parse(name: String, value: String, parserSettings: 
ParserSettings): (Uri.Path, Option[String]) =
@@ -60,7 +61,11 @@ private[pekko] object Http2HeaderParsing {
   }
   object ContentType extends HeaderParser[model.ContentType]("content-type") {
     override def parse(name: String, value: String, parserSettings: 
ParserSettings): model.ContentType =
-      model.ContentType.parse(value).getOrElse(malformedRequest(s"Invalid 
content-type: '$value'"))
+      model.ContentType.parse(value) match {
+        case Right(tpe) => tpe
+        case Left(_)    =>
+          parseError(s"Invalid content-type: '$value'", "content-type")
+      }
   }
   object ContentLength extends Verbatim("content-length")
   object Cookie extends Verbatim("cookie")
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientServerSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientServerSpec.scala
index 3885c8ffe..96ddec9f6 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientServerSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientServerSpec.scala
@@ -30,6 +30,7 @@ import pekko.http.scaladsl.model.{
   HttpHeader,
   HttpMethod,
   HttpMethods,
+  HttpProtocols,
   HttpRequest,
   HttpResponse,
   RequestResponseAssociation,
@@ -132,6 +133,17 @@ class Http2ClientServerSpec extends 
PekkoSpecWithMaterializer(
       // expect idle timeout exception to propagate to user
       clientResponsesIn.expectError() shouldBe a[HttpIdleTimeoutException]
     }
+
+    "return bad request response when header parsing fails" in new TestSetup {
+      val badRequest = HttpRequest(
+        // easiest valid way to cause parsing to fail - unknown method
+        method = HttpMethod.custom("UNKNOWN_TO_SERVER"),
+        uri = "http://www.example.com/test";
+      )
+      sendClientRequest(badRequest)
+      val response = expectClientResponse()
+      response.status should be(StatusCodes.BadRequest)
+    }
   }
 
   case class ServerRequest(request: HttpRequest, promise: 
Promise[HttpResponse]) {
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerDemuxSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerDemuxSpec.scala
index 8be03fa12..55f6adc39 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerDemuxSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ServerDemuxSpec.scala
@@ -54,7 +54,7 @@ class Http2ServerDemuxSpec extends 
PekkoSpecWithMaterializer("""
       // The request is taken from the HTTP/1.1 request that had the Upgrade
       // header and is passed to the handler code 'directly', bypassing the 
demux stage,
       // so the first thing the demux stage sees of this request is the 
response:
-      val response = ParsedHeadersFrame(streamId = 1, endStream = true, 
Seq((":status", "200")), None)
+      val response = ParsedHeadersFrame(streamId = 1, endStream = true, 
Seq((":status", "200")), None, None)
       substreamProducer.sendNext(new Http2SubStream(
         response,
         OptionVal.None,
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 a844bd762..9c692d577 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
@@ -21,6 +21,8 @@ import FrameEvent._
 import org.apache.pekko
 import pekko.http.impl.engine.http2.hpack.HeaderDecompression
 import pekko.http.impl.engine.parsing.HttpHeaderParser
+import pekko.http.impl.engine.http2.Http2Compliance.Http2ProtocolException
+import pekko.http.impl.engine.http2.RequestParsing.ParseRequestResult
 import pekko.http.impl.engine.server.HttpAttributes
 import pekko.http.impl.util.PekkoSpecWithMaterializer
 import pekko.http.scaladsl.model._
@@ -31,6 +33,7 @@ import pekko.stream.scaladsl.{ Sink, Source }
 import pekko.util.{ ByteString, OptionVal }
 
 import org.scalatest.{ Inside, Inspectors }
+import org.scalatest.exceptions.TestFailedException
 
 class RequestParsingSpec extends PekkoSpecWithMaterializer with Inside with 
Inspectors {
   "RequestParsing" should {
@@ -38,10 +41,10 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
     /** Helper to test parsing */
     def parse(
         keyValuePairs: Seq[(String, String)],
-        data: Source[ByteString, Any] = Source.empty,
-        attributes: Attributes = Attributes(),
-        uriParsingMode: Uri.ParsingMode = Uri.ParsingMode.Relaxed,
-        settings: ServerSettings = ServerSettings(system)): HttpRequest = {
+        data: Source[ByteString, Any],
+        attributes: Attributes,
+        uriParsingMode: Uri.ParsingMode,
+        settings: ServerSettings): RequestParsing.ParseRequestResult = {
       val (serverSettings, parserSettings) = {
         val ps = settings.parserSettings.withUriParsingMode(uriParsingMode)
         (settings.withParserSettings(ps), ps)
@@ -52,30 +55,59 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       val frame =
         HeadersFrame(1, data == Source.empty, endHeaders = true, 
encoder.encodeHeaderPairs(keyValuePairs), None)
 
-      val parseRequest: Http2SubStream => HttpRequest =
-        RequestParsing.parseRequest(headerParser, serverSettings, attributes)
-
-      try Source.single(frame)
-          .via(new HeaderDecompression(headerParser, parserSettings))
-          .map { // emulate demux
-            case headers: ParsedHeadersFrame =>
-              Http2SubStream(
-                initialHeaders = headers,
-                trailingHeaders = OptionVal.None,
-                data = Right(data),
-                correlationAttributes = Map.empty)
-          }
-          .map(parseRequest)
-          .runWith(Sink.head)
-          .futureValue
-      catch { case ex: Throwable => throw ex.getCause } // unpack futureValue 
exceptions
+      val parseRequest: Http2SubStream => ParseRequestResult = 
RequestParsing.parseRequest(headerParser, serverSettings,
+        attributes)
+
+      Source.single(frame)
+        .via(new HeaderDecompression(headerParser, parserSettings))
+        .map { // emulate demux
+          case headers: ParsedHeadersFrame =>
+            Http2SubStream(
+              initialHeaders = headers,
+              trailingHeaders = OptionVal.None,
+              data = Right(data),
+              correlationAttributes = Map.empty)
+        }
+        .map(parseRequest)
+        .runWith(Sink.head)
+        .futureValue
     }
 
-    def shouldThrowMalformedRequest[T](block: => T): Exception = {
-      val thrown = the[RuntimeException] thrownBy block
-      thrown.getMessage should startWith("Malformed request: ")
-      thrown
-    }
+    def parseExpectOk(
+        keyValuePairs: Seq[(String, String)],
+        data: Source[ByteString, Any] = Source.empty,
+        attributes: Attributes = Attributes(),
+        uriParsingMode: Uri.ParsingMode = Uri.ParsingMode.Relaxed,
+        settings: ServerSettings = ServerSettings(system)): HttpRequest =
+      parse(keyValuePairs, data, attributes, uriParsingMode, settings) match {
+        case RequestParsing.OkRequest(req)      => req
+        case RequestParsing.BadRequest(info, _) => fail(s"Failed parsing 
request: $info")
+      }
+
+    def parseExpectError(
+        keyValuePairs: Seq[(String, String)],
+        data: Source[ByteString, Any] = Source.empty,
+        attributes: Attributes = Attributes(),
+        uriParsingMode: Uri.ParsingMode = Uri.ParsingMode.Relaxed,
+        settings: ServerSettings = ServerSettings(system)): ErrorInfo =
+      parse(keyValuePairs, data, attributes, uriParsingMode, settings) match {
+        case RequestParsing.OkRequest(req)      => fail("Unexpectedly 
succeeded parsing request")
+        case RequestParsing.BadRequest(info, _) => info
+      }
+
+    def parseExpectProtocolError(
+        keyValuePairs: Seq[(String, String)],
+        data: Source[ByteString, Any] = Source.empty,
+        attributes: Attributes = Attributes(),
+        uriParsingMode: Uri.ParsingMode = Uri.ParsingMode.Relaxed,
+        settings: ServerSettings = ServerSettings(system)): 
Http2ProtocolException =
+      try {
+        parse(keyValuePairs, data, attributes, uriParsingMode, settings)
+        fail("expected parsing to throw")
+      } catch {
+        case futureValueEx: TestFailedException if 
futureValueEx.getCause.isInstanceOf[Http2ProtocolException] =>
+          futureValueEx.getCause.asInstanceOf[Http2ProtocolException]
+      }
 
     "follow RFC7540" should {
 
@@ -85,13 +117,13 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       // appear in requests.
 
       "not accept response pseudo-header fields in a request" in {
-        val thrown = shouldThrowMalformedRequest(parse(
+        val ex = parseExpectProtocolError(
           keyValuePairs = Vector(
             ":scheme" -> "https",
             ":method" -> "GET",
             ":path" -> "/",
-            ":status" -> "200")))
-        thrown.getMessage should ===(
+            ":status" -> "200"))
+        ex.getMessage should ===(
           "Malformed request: Pseudo-header ':status' is for responses only; 
it cannot appear in a request")
       }
 
@@ -109,7 +141,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           // 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
-          shouldThrowMalformedRequest(parse(modified))
+          parseExpectProtocolError(modified)
         }
       }
 
@@ -119,32 +151,30 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
       // be treated as malformed...
 
       "not accept connection-specific headers" in {
-        shouldThrowMalformedRequest {
-          // Add Connection header to indicate that Foo is a 
connection-specific header
-          parse(Vector(
-            ":method" -> "GET",
-            ":scheme" -> "https",
-            ":path" -> "/",
-            "Connection" -> "foo",
-            "Foo" -> "bar"))
-        }
+        // Add Connection header to indicate that Foo is a connection-specific 
header
+        parseExpectProtocolError(Vector(
+          ":method" -> "GET",
+          ":scheme" -> "https",
+          ":path" -> "/",
+          "Connection" -> "foo",
+          "Foo" -> "bar"))
       }
 
       "not accept TE with other values than 'trailers'" in {
-        shouldThrowMalformedRequest {
-          // The only exception to this is the TE header field, which MAY be
-          // present in an HTTP/2 request; when it is, it MUST NOT contain any
-          // value other than "trailers".
-          parse(Vector(
-            ":method" -> "GET",
-            ":scheme" -> "https",
-            ":path" -> "/",
-            "TE" -> "chunked"))
-        }
+
+        // The only exception to this is the TE header field, which MAY be
+        // present in an HTTP/2 request; when it is, it MUST NOT contain any
+        // value other than "trailers".
+        parseExpectProtocolError(Vector(
+          ":method" -> "GET",
+          ":scheme" -> "https",
+          ":path" -> "/",
+          "TE" -> "chunked"))
+
       }
 
       "accept TE with 'trailers' as value" in {
-        parse(Vector(
+        parseExpectOk(Vector(
           ":method" -> "GET",
           ":scheme" -> "https",
           ":path" -> "/",
@@ -159,7 +189,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       "parse the ':method' pseudo-header correctly" in {
         val methods = Seq("GET", "POST", "DELETE", "OPTIONS")
         forAll(methods) { (method: String) =>
-          val request: HttpRequest = parse(
+          val request: HttpRequest = parseExpectOk(
             keyValuePairs = Vector(
               ":method" -> method,
               ":scheme" -> "https",
@@ -181,7 +211,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
         // can't be constructed with any other schemes.
         val schemes = Seq("http", "https", "ws", "wss")
         forAll(schemes) { (scheme: String) =>
-          val request: HttpRequest = parse(
+          val request: HttpRequest = parseExpectOk(
             keyValuePairs = Vector(
               ":method" -> "POST",
               ":scheme" -> scheme,
@@ -206,7 +236,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
             ("example.com:8042", "example.com", Some(8042)))
           forAll(authorities) {
             case (authority, host, optPort) =>
-              val request: HttpRequest = parse(
+              val request: HttpRequest = parseExpectOk(
                 keyValuePairs = Vector(
                   ":method" -> "POST",
                   ":scheme" -> "https",
@@ -221,14 +251,13 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
 
           val authorities = Seq("?", " ", "@", ":")
           forAll(authorities) { authority =>
-            val thrown = the[ParsingException] thrownBy
-              (parse(
-                keyValuePairs = Vector(
-                  ":method" -> "POST",
-                  ":scheme" -> "https",
-                  ":authority" -> authority,
-                  ":path" -> "/")))
-            thrown.getMessage should include("http2-authority-pseudo-header")
+            val info = parseExpectError(
+              keyValuePairs = Vector(
+                ":method" -> "POST",
+                ":scheme" -> "https",
+                ":authority" -> authority,
+                ":path" -> "/"))
+            info.summary should include("http2-authority-pseudo-header")
           }
         }
       }
@@ -249,14 +278,14 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
         val schemes = Seq("http", "https")
         forAll(schemes) { (scheme: String) =>
           forAll(authorities) { (authority: String) =>
-            val exception = the[Exception] thrownBy
-              (parse(
-                keyValuePairs = Vector(
-                  ":method" -> "POST",
-                  ":scheme" -> scheme,
-                  ":authority" -> authority,
-                  ":path" -> "/")))
-            exception.getMessage should startWith("Illegal 
http2-authority-pseudo-header")
+            val info = parseExpectError(
+              keyValuePairs = Vector(
+                ":method" -> "POST",
+                ":scheme" -> scheme,
+                ":authority" -> authority,
+                ":path" -> "/"
+              ))
+            info.summary should startWith("Illegal 
http2-authority-pseudo-header")
           }
         }
       }
@@ -269,9 +298,14 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       "follow RFC3986 for the ':path' pseudo-header" should {
 
         def parsePath(path: String, uriParsingMode: Uri.ParsingMode = 
Uri.ParsingMode.Relaxed): Uri = {
-          parse(Seq(":method" -> "GET", ":scheme" -> "https", ":path" -> 
path), uriParsingMode = uriParsingMode).uri
+          parseExpectOk(Seq(":method" -> "GET", ":scheme" -> "https", ":path" 
-> path),
+            uriParsingMode = uriParsingMode).uri
         }
 
+        def parsePathExpectError(path: String, uriParsingMode: Uri.ParsingMode 
= Uri.ParsingMode.Relaxed): ErrorInfo =
+          parseExpectError(Seq(":method" -> "GET", ":scheme" -> "https", 
":path" -> path),
+            uriParsingMode = uriParsingMode)
+
         // sub-delims  = "!" / "$" / "&" / "'" / "(" / ")"
         //             / "*" / "+" / "," / ";" / "="
 
@@ -333,8 +367,8 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
             "?", "&", "=", "#", ":", "?", "#", "[", "]", "@", " ",
             "http://localhost/foo";)
           forAll(invalidAbsolutePaths) { (absPath: String) =>
-            val exception = the[ParsingException] thrownBy (parsePath(absPath))
-            exception.getMessage should include("http2-path-pseudo-header")
+            val info = parsePathExpectError(absPath)
+            info.summary should include("http2-path-pseudo-header")
           }
         }
 
@@ -343,8 +377,8 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
             // Illegal for path-absolute in RFC3986 to start with multiple 
slashes
             "//", "//x")
           forAll(invalidAbsolutePaths) { (absPath: String) =>
-            val exception = the[ParsingException] thrownBy (parsePath(absPath, 
uriParsingMode = Uri.ParsingMode.Strict))
-            exception.getMessage should include("http2-path-pseudo-header")
+            val info = parsePathExpectError(absPath, uriParsingMode = 
Uri.ParsingMode.Strict)
+            info.summary should include("http2-path-pseudo-header")
           }
         }
 
@@ -383,13 +417,22 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
           }
         }
 
+        // Regression test for https://github.com/apache/pekko-http/issues/59
+        // '%_D' is invalid percent-encoding because '_' is not a hex digit;
+        // should return 400 Bad Request instead of terminating the connection
+        "reject a ':path' containing invalid percent-encoding" in {
+          val info = parsePathExpectError("/?param=%_D")
+          info.summary should include("http2-path-pseudo-header")
+        }
+
         "reject a ':path' containing an invalid 'query'" in pendingUntilFixed {
           val invalidQueries: Seq[String] = Seq(
             ":", "/", "?", "#", "[", "]", "@", " ")
+
           forAll(absolutePaths.take(3)) {
             case (inputPath, _) =>
               forAll(invalidQueries) { (query: String) =>
-                shouldThrowMalformedRequest(parsePath(inputPath + "?" + query, 
uriParsingMode = Uri.ParsingMode.Strict))
+                parsePathExpectError(inputPath + "?" + query, uriParsingMode = 
Uri.ParsingMode.Strict)
               }
           }
         }
@@ -400,7 +443,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       // value '*' for the ":path" pseudo-header field.
 
       "handle a ':path' with an asterisk" in pendingUntilFixed {
-        val request: HttpRequest = parse(
+        val request: HttpRequest = parseExpectOk(
           keyValuePairs = Vector(
             ":method" -> "OPTIONS",
             ":scheme" -> "http",
@@ -411,14 +454,14 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
       // [The ":path"] pseudo-header field MUST NOT be empty for "http" or 
"https"
       // URIs...
 
-      "reject empty ':path' pseudo-headers for http and https" in 
pendingUntilFixed {
+      "reject empty ':path' pseudo-headers for http and https" in {
         val schemes = Seq("http", "https")
         forAll(schemes) { (scheme: String) =>
-          shouldThrowMalformedRequest(parse(
+          parseExpectProtocolError(
             keyValuePairs = Vector(
               ":method" -> "POST",
               ":scheme" -> scheme,
-              ":path" -> "")))
+              ":path" -> ""))
         }
       }
 
@@ -439,12 +482,12 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
       "reject requests without a mandatory pseudo-headers" in {
         val mandatoryPseudoHeaders = Seq(":method", ":scheme", ":path")
         forAll(mandatoryPseudoHeaders) { (name: String) =>
-          val thrown = shouldThrowMalformedRequest(parse(
+          val ex = parseExpectProtocolError(
             keyValuePairs = Vector(
               ":scheme" -> "https",
               ":method" -> "GET",
-              ":path" -> "/").filter(_._1 != name)))
-          thrown.getMessage should ===(s"Malformed request: Mandatory 
pseudo-header '$name' missing")
+              ":path" -> "/").filter(_._1 != name))
+          ex.getMessage should ===(s"Malformed request: Mandatory 
pseudo-header '$name' missing")
         }
       }
 
@@ -453,13 +496,13 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
           Seq(":method" -> "POST", ":scheme" -> "http", ":path" -> "/other", 
":authority" -> "example.org")
         forAll(pseudoHeaders) {
           case (name: String, alternative: String) =>
-            val thrown = shouldThrowMalformedRequest(parse(
+            val ex = parseExpectProtocolError(
               keyValuePairs = Vector(
                 ":scheme" -> "https",
                 ":method" -> "GET",
                 ":authority" -> "pekko.apache.org",
-                ":path" -> "/") :+ (name -> alternative)))
-            thrown.getMessage should ===(s"Malformed request: Pseudo-header 
'$name' must not occur more than once")
+                ":path" -> "/") :+ (name -> alternative))
+            ex.getMessage should ===(s"Malformed request: Pseudo-header 
'$name' must not occur more than once")
         }
       }
 
@@ -479,7 +522,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
           Seq("a=b", "c=d; e=f") -> "a=b; c=d; e=f")
         forAll(cookieHeaders) {
           case (inValues, outValue) =>
-            val httpRequest: HttpRequest = parse(
+            val httpRequest: HttpRequest = parseExpectOk(
               Vector(
                 ":method" -> "GET",
                 ":scheme" -> "https",
@@ -495,7 +538,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       // 8.1.3.  Examples
 
       "parse GET example" in {
-        val request: HttpRequest = parse(
+        val request: HttpRequest = parseExpectOk(
           keyValuePairs = Vector(
             ":method" -> "GET",
             ":scheme" -> "https",
@@ -518,7 +561,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
       }
 
       "parse POST example" in {
-        val request: HttpRequest = parse(
+        val request: HttpRequest = parseExpectOk(
           keyValuePairs = Vector(
             ":method" -> "POST",
             ":scheme" -> "https",
@@ -552,7 +595,7 @@ class RequestParsingSpec extends PekkoSpecWithMaterializer 
with Inside with Insp
     // Tests that don't come from an RFC document...
 
     "parse GET https://localhost:8000/ correctly" in {
-      val request: HttpRequest = parse(
+      val request: HttpRequest = parseExpectOk(
         keyValuePairs = Vector(
           ":method" -> "GET",
           ":scheme" -> "https",
@@ -571,42 +614,41 @@ class RequestParsingSpec extends 
PekkoSpecWithMaterializer with Inside with Insp
     }
 
     "reject requests with multiple content length headers" in {
-      val thrown = shouldThrowMalformedRequest(parse(
+      val info = parseExpectError(
         keyValuePairs = Vector(
           ":method" -> "GET",
           ":scheme" -> "https",
           ":authority" -> "localhost:8000",
           ":path" -> "/",
           "content-length" -> "123",
-          "content-length" -> "124")))
-      thrown.getMessage should ===(
+          "content-length" -> "124"))
+      info.summary should ===(
         s"Malformed request: HTTP message must not contain more than one 
content-length header")
     }
 
     "reject requests with multiple content type headers" in {
-      val thrown = shouldThrowMalformedRequest(parse(
+      val info = parseExpectError(
         keyValuePairs = Vector(
           ":method" -> "GET",
           ":scheme" -> "https",
           ":authority" -> "localhost:8000",
           ":path" -> "/",
           "content-type" -> "text/json",
-          "content-type" -> "text/json")))
-      thrown.getMessage should ===(
+          "content-type" -> "text/json"))
+      info.summary should ===(
         s"Malformed request: HTTP message must not contain more than one 
content-type header")
     }
 
     "reject requests with too many headers" in {
       val maxHeaderCount = ServerSettings(system).parserSettings.maxHeaderCount
-      val thrown = shouldThrowMalformedRequest(
-        parse((0 to (maxHeaderCount + 1)).map(n => s"x-my-header-$n" -> 
n.toString).toVector))
-      thrown.getMessage should ===(
+      val info = parseExpectError((0 to (maxHeaderCount + 1)).map(n => 
s"x-my-header-$n" -> n.toString).toVector)
+      info.summary should ===(
         s"Malformed request: HTTP message contains more than the configured 
limit of $maxHeaderCount headers")
     }
 
     "add remote address request attribute if enabled" in {
       val theAddress = InetAddress.getByName("127.5.2.1")
-      val request: HttpRequest = parse(
+      val request: HttpRequest = parseExpectOk(
         keyValuePairs = Vector(
           ":method" -> "GET",
           ":scheme" -> "https",


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

Reply via email to