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]