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 0808b90b2 fix: send ':scheme' and ':authority' for a relative URI over 
HTTP/2 (#1342)
0808b90b2 is described below

commit 0808b90b2c507708bfe76fed6d55db3c2dee2818
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Oct 13 10:32:54 2026 +0100

    fix: send ':scheme' and ':authority' for a relative URI over HTTP/2 (#1342)
    
    Motivation:
    The HTTP/2 client rendered ':scheme' and ':authority' straight from the 
request URI, so a
    request with a relative URI - the usual form with the connection-level 
client API -
    was sent with both empty. RFC 9113 8.3.1 makes ':scheme' mandatory and 
forbids an empty
    ':authority'. A Pekko HTTP server saw the request URI as `/foo` rather than
    `http://host:port/foo`, and stricter servers may reject the request.
    
    Modification:
    - RequestRendering takes the connection's scheme and authority and uses 
them when the
      request URI has none; a non-empty Host header supplies the authority 
first, as it
      does for HTTP/1.1. An absolute URI is rendered as before.
    - Http2.outgoingConnection passes `https` and 
outgoingConnectionPriorKnowledge `http`,
      with the connection's host and port (port left out when it is the 
scheme's default).
    - Http2Blueprint.clientStack and httpLayerClient keep their existing 
signatures, which
      render as before, and gain overloads taking the connection scheme and 
authority.
    
    Result:
    Requests with a relative URI carry a valid ':scheme' and ':authority'.
    
    Tests:
    - Without the main change the new HttpMessageRenderingSpec relative-URI and 
Host
      header tests and the WithPriorKnowledgeSpec end-to-end test fail
    - sbt http2-tests/test: 390 passed
    - sbt http-core/mimaReportBinaryIssues: pass
    - scalafmt applied to http-core and http2-tests
    
    References:
    None - follow-up to #1341, which found the Pekko client sending an empty 
':scheme'
---
 .../pekko/http/impl/engine/http2/Http2.scala       | 10 ++++++--
 .../http/impl/engine/http2/Http2Blueprint.scala    | 19 +++++++++++++--
 .../impl/engine/http2/HttpMessageRendering.scala   | 21 +++++++++++++----
 .../engine/http2/HttpMessageRenderingSpec.scala    | 27 ++++++++++++++++++++++
 .../impl/engine/http2/WithPriorKnowledgeSpec.scala | 13 +++++++++++
 5 files changed, 82 insertions(+), 8 deletions(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2.scala 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2.scala
index 4c86c3bcc..e89190405 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/Http2.scala
@@ -276,7 +276,8 @@ private[http] final class Http2Ext(implicit val system: 
ActorSystem)
       engine
     }
 
-    val stack = Http2Blueprint.clientStack(clientConnectionSettings, log, 
telemetry).addAttributes(
+    val stack = Http2Blueprint.clientStack(clientConnectionSettings, log, 
telemetry, "https",
+      connectionAuthority(host, port, "https")).addAttributes(
       prepareClientAttributes(host, port)).atop(
       Http2Blueprint.unwrapTls).atop(
       LogByteStringTools.logTLSBidiBySetting("client-plain-text",
@@ -290,7 +291,8 @@ private[http] final class Http2Ext(implicit val system: 
ActorSystem)
 
   def outgoingConnectionPriorKnowledge(host: String, port: Int, 
clientConnectionSettings: ClientConnectionSettings,
       log: LoggingAdapter): Flow[HttpRequest, HttpResponse, 
Future[OutgoingConnection]] = {
-    val stack = Http2Blueprint.clientStack(clientConnectionSettings, log, 
telemetry).addAttributes(
+    val stack = Http2Blueprint.clientStack(clientConnectionSettings, log, 
telemetry, "http",
+      connectionAuthority(host, port, "http")).addAttributes(
       prepareClientAttributes(host, port)).atop(
       Http2Blueprint.unwrapTls).atop(
       LogByteStringTools.logTLSBidiBySetting("client-plain-text",
@@ -302,6 +304,10 @@ private[http] final class Http2Ext(implicit val system: 
ActorSystem)
       
.addAttributes(Http.cancellationStrategyAttributeForDelay(clientConnectionSettings.streamCancellationDelay))
   }
 
+  // the port is left out when it is the scheme's default, as a `Host` header 
for the same target would leave it out
+  private def connectionAuthority(host: String, port: Int, scheme: String): 
Uri.Authority =
+    Uri.Authority(Uri.Host(host), Uri.normalizePort(port, scheme))
+
   private def prepareClientAttributes(serverHost: String, port: Int): 
Attributes =
     if (telemetry == NoOpTelemetry) Attributes.none
     else TelemetryAttributes.prepareClientFlowAttributes(serverHost, port)
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 fe4ac2ef3..9ceb37703 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
@@ -157,13 +157,22 @@ private[http] object Http2Blueprint {
   // format: ON
 
   def clientStack(settings: ClientConnectionSettings, log: LoggingAdapter, 
telemetry: TelemetrySpi)
+      : BidiFlow[HttpRequest, ByteString, ByteString, HttpResponse, NotUsed] =
+    clientStack(settings, log, telemetry, "", Uri.Authority.Empty)
+
+  /**
+   * @param connectionScheme    the ':scheme' rendered for a request whose URI 
has none
+   * @param connectionAuthority the ':authority' rendered for a request whose 
URI and `Host` header have none
+   */
+  def clientStack(settings: ClientConnectionSettings, log: LoggingAdapter, 
telemetry: TelemetrySpi,
+      connectionScheme: String, connectionAuthority: Uri.Authority)
       : BidiFlow[HttpRequest, ByteString, ByteString, HttpResponse, NotUsed] = 
{
     // This is master header parser, every other usage should do 
.createShallowCopy()
     // 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(settings.parserSettings, log)
     telemetry.client.atop(
-      httpLayerClient(masterHttpHeaderParser, settings, log)).atop(
+      httpLayerClient(masterHttpHeaderParser, settings, log, connectionScheme, 
connectionAuthority)).atop(
       clientDemux(settings.http2Settings, masterHttpHeaderParser)).atop(
       FrameLogger.logFramesIfEnabled(settings.http2Settings.logFrames)).atop( 
// enable for debugging
       hpackCoding(masterHttpHeaderParser, settings.parserSettings, 
settings.http2Settings.maxHeaderListSize)).atop(
@@ -174,8 +183,14 @@ private[http] object Http2Blueprint {
 
   def httpLayerClient(masterHttpHeaderParser: HttpHeaderParser, settings: 
ClientConnectionSettings, log: LoggingAdapter)
       : BidiFlow[HttpRequest, Http2SubStream, Http2SubStream, HttpResponse, 
NotUsed] =
+    httpLayerClient(masterHttpHeaderParser, settings, log, "", 
Uri.Authority.Empty)
+
+  def httpLayerClient(masterHttpHeaderParser: HttpHeaderParser, settings: 
ClientConnectionSettings, log: LoggingAdapter,
+      connectionScheme: String, connectionAuthority: Uri.Authority)
+      : BidiFlow[HttpRequest, Http2SubStream, Http2SubStream, HttpResponse, 
NotUsed] =
     BidiFlow.fromFlows(
-      Flow[HttpRequest].statefulMap(() => new RequestRendering(settings, 
log))((renderer, request) =>
+      Flow[HttpRequest].statefulMap(() =>
+        new RequestRendering(settings, log, connectionScheme, 
connectionAuthority))((renderer, request) =>
           (renderer, renderer(request)), _ => None),
       StreamUtils.statefulAttrsMap[Http2SubStream, HttpResponse] { attrs =>
         val headerParser = masterHttpHeaderParser.createShallowCopy()
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 5d999ebd5..156551e8e 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
@@ -20,7 +20,7 @@ import pekko.event.LoggingAdapter
 import pekko.http.impl.engine.http2.FrameEvent.ParsedHeadersFrame
 import pekko.http.impl.engine.rendering.DateHeaderRendering
 import pekko.http.scaladsl.model._
-import pekko.http.scaladsl.model.headers.`Raw-Request-URI`
+import pekko.http.scaladsl.model.headers.{ `Raw-Request-URI`, Host }
 import pekko.http.scaladsl.settings.ClientConnectionSettings
 import pekko.http.scaladsl.settings.ServerSettings
 import pekko.util.OptionVal
@@ -64,7 +64,9 @@ private[http2] class ResponseRendering(settings: 
ServerSettings, val log: Loggin
 /** INTERNAL API */
 @InternalApi
 private[http2] class RequestRendering(
-    settings: ClientConnectionSettings, val log: LoggingAdapter) extends 
MessageRendering[HttpRequest] {
+    settings: ClientConnectionSettings, val log: LoggingAdapter,
+    connectionScheme: String = "", connectionAuthority: Uri.Authority = 
Uri.Authority.Empty)
+    extends MessageRendering[HttpRequest] {
 
   private val streamId = new AtomicInteger(1)
   protected override def nextStreamId(r: HttpRequest): Int = 
streamId.getAndAdd(2)
@@ -72,8 +74,10 @@ private[http2] class RequestRendering(
   protected override def initialHeaderPairs(request: HttpRequest): 
VectorBuilder[(String, String)] = {
     val headerPairs = new VectorBuilder[(String, String)]()
     headerPairs += ":method" -> request.method.value
-    headerPairs += ":scheme" -> request.uri.scheme
-    headerPairs += ":authority" -> request.uri.authority.toString
+    // RFC 9113 8.3.1: ':scheme' is mandatory and ':authority', when present, 
must not be empty. A relative URI, as
+    // used with the connection-level client API, carries neither, so they are 
taken from the connection.
+    headerPairs += ":scheme" -> (if (request.uri.scheme.nonEmpty) 
request.uri.scheme else connectionScheme)
+    headerPairs += ":authority" -> authority(request)
     // a `Raw-Request-URI` header supplies the request target verbatim, as it 
does in the HTTP/1.1 renderer. `Uri`
     // percent-decodes path segments when parsing and re-encodes them with a 
keep-set that leaves sub-delims raw, so
     // it cannot round-trip an encoded pchar (`%2B` renders as `+`). Callers 
that must reproduce the target
@@ -83,6 +87,15 @@ private[http2] class RequestRendering(
     headerPairs
   }
 
+  // a `Host` header names the authority of a relative URI, as it does in 
HTTP/1.1, ahead of the connection's
+  private def authority(request: HttpRequest): String =
+    if (!request.uri.authority.isEmpty) request.uri.authority.toString
+    else
+      request.header[Host] match {
+        case Some(host) if !host.isEmpty => Uri.Authority(host.host, 
host.port).toString
+        case _                           => connectionAuthority.toString
+      }
+
   // `Raw-Request-URI` is a SyntheticHeader, so it is already excluded from 
the rendered header block by the
   // `renderInRequests` filter and is only consumed here. As in HTTP/1.1, the 
value is taken as given; the header
   // rejects anything but visible ASCII on construction, so it cannot carry a 
character the HPACK guard would drop
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
index 87f09dac1..2d854e092 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRenderingSpec.scala
@@ -150,6 +150,26 @@ class HttpMessageRenderingSpec extends AnyWordSpec with 
Matchers {
       value1.exists(_._1 == "date") shouldBe false
     }
 
+    "take ':scheme' and ':authority' of a relative URI from the connection" in 
{
+      // RFC 9113 8.3.1: ':scheme' is mandatory and ':authority' must not be 
empty
+      pseudoHeaders(HttpRequest(uri = "/foo"), "https", 
Uri.Authority(Uri.Host("example.com"))) shouldEqual
+      Map(":method" -> "GET", ":scheme" -> "https", ":authority" -> 
"example.com", ":path" -> "/foo")
+      pseudoHeaders(HttpRequest(uri = "/foo"), "http", 
Uri.Authority(Uri.Host("example.com"), 8080)) shouldEqual
+      Map(":method" -> "GET", ":scheme" -> "http", ":authority" -> 
"example.com:8080", ":path" -> "/foo")
+    }
+
+    "take ':authority' of a relative URI from a Host header before the 
connection" in {
+      val request = HttpRequest(uri = "/foo", headers = 
List(Host("other.example.com", 8443)))
+      pseudoHeaders(request, "https", Uri.Authority(Uri.Host("example.com"))) 
should contain(
+        ":authority" -> "other.example.com:8443")
+    }
+
+    "keep ':scheme' and ':authority' of an absolute URI" in {
+      pseudoHeaders(HttpRequest(uri = "http://other.example.com:8080/foo";), 
"https",
+        Uri.Authority(Uri.Host("example.com"))) shouldEqual
+      Map(":method" -> "GET", ":scheme" -> "http", ":authority" -> 
"other.example.com:8080", ":path" -> "/foo")
+    }
+
     "handle empty trailer" in {
       val config = ConfigFactory.load("reference.conf")
       Try {
@@ -168,6 +188,13 @@ class HttpMessageRenderingSpec extends AnyWordSpec with 
Matchers {
 
   }
 
+  private def pseudoHeaders(request: HttpRequest, connectionScheme: String, 
connectionAuthority: Uri.Authority)
+      : Map[String, AnyRef] = {
+    val rendering = new 
RequestRendering(ClientConnectionSettings(ConfigFactory.load("reference.conf")),
 NoLogging,
+      connectionScheme, connectionAuthority)
+    
rendering(request).initialHeaders.keyValuePairs.filter(_._1.startsWith(":")).toMap
+  }
+
   private def renderClientHeaders(headers: Seq[HttpHeader], builder: 
VectorBuilder[(String, String)],
       peerIdHeader: Option[(String, String)] = None): Unit =
     HttpMessageRendering.renderHeaders(headers, builder, peerIdHeader, 
NoLogging, isServer = false,
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/WithPriorKnowledgeSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/WithPriorKnowledgeSpec.scala
index a9a8114d3..6c3e3312e 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/WithPriorKnowledgeSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/WithPriorKnowledgeSpec.scala
@@ -16,6 +16,7 @@ package org.apache.pekko.http.impl.engine.http2
 import java.util.Base64
 
 import scala.concurrent.Future
+import scala.concurrent.duration._
 
 import org.apache.pekko
 import pekko.http.impl.util.PekkoSpecWithMaterializer
@@ -87,5 +88,17 @@ class WithPriorKnowledgeSpec extends 
PekkoSpecWithMaterializer("""
       response.status should ===(StatusCodes.ImATeapot)
       queue.complete()
     }
+
+    "send ':scheme' and ':authority' for a relative URI from the connection 
(connection level client API)" in {
+      val echoBinding = Http().newServerAt("127.0.0.1", 0).bind(request =>
+        Future.successful(HttpResponse(entity = 
request.uri.toString))).futureValue
+      val (host, port) = (echoBinding.localAddress.getHostName, 
echoBinding.localAddress.getPort)
+      val connectionFlow = 
Http().connectionTo(host).toPort(port).http2WithPriorKnowledge()
+
+      val response = Source.single(HttpRequest(uri = 
"/foo")).via(connectionFlow).runWith(Sink.head).futureValue
+      response.status should ===(StatusCodes.OK)
+      response.entity.toStrict(3.seconds).futureValue.data.utf8String should 
===(s"http://$host:$port/foo";)
+      echoBinding.unbind().futureValue
+    }
   }
 }


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

Reply via email to