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 eed92b063 feat: honour Raw-Request-URI as the HTTP/2 :path (#1280)
eed92b063 is described below

commit eed92b063f05ec6b15f053d716338234f4af1660
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 11 09:53:21 2026 +0100

    feat: honour Raw-Request-URI as the HTTP/2 :path (#1280)
    
    The HTTP/1.1 request renderer lets a caller supply the request target
    verbatim through a `Raw-Request-URI` header, bypassing `Uri` rendering. The
    HTTP/2 renderer built `:path` from `request.uri` unconditionally, so the
    same request produced a different target depending on the protocol
    negotiated.
    
    Take the header into account when building `:path`, as HTTP/1.1 does. The
    header is a `SyntheticHeader`, so it was already excluded from the rendered
    header block by the `renderInRequests` filter and is only consumed here. As
    in HTTP/1.1 the value is used as given -- supplying a valid origin-form
    target is the caller's responsibility.
    
    This matters because `Uri` cannot round-trip a percent-encoded path: it
    decodes segments when parsing and re-encodes them with a keep-set that
    leaves sub-delims raw, so `%2B` renders back as `+`. Callers that must
    reproduce a target byte-for-byte -- AWS SigV4 signs the encoded path, so an
    S3 key containing `+` or `=` fails with SignatureDoesNotMatch otherwise --
    had no way to do so over HTTP/2.
    
    Refs #1273.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 docs/src/main/paradox/common/http-model.md         | 10 ++++++
 .../impl/engine/http2/HttpMessageRendering.scala   | 14 ++++++++-
 .../http/impl/engine/http2/Http2ClientSpec.scala   | 36 ++++++++++++++++++++++
 3 files changed, 59 insertions(+), 1 deletion(-)

diff --git a/docs/src/main/paradox/common/http-model.md 
b/docs/src/main/paradox/common/http-model.md
index a9d693473..cd399bcf1 100644
--- a/docs/src/main/paradox/common/http-model.md
+++ b/docs/src/main/paradox/common/http-model.md
@@ -103,6 +103,16 @@ Scala
 Java
 :   @@snip 
[ModelDocTest.java](/docs/src/test/java/docs/http/javadsl/ModelDocTest.java) { 
#synthetic-header-s3 }
 
+The `Raw-Request-URI` header is honoured by both the HTTP/1.1 and the HTTP/2 
client; over HTTP/2 its value is sent as
+the `:path` pseudo-header. It is consumed by the request engine and never 
rendered as a header of its own, and its
+value is used exactly as given — it is the caller's responsibility to supply a 
valid request target.
+
+This is the supported way to send a request target that @apidoc[Uri] cannot 
reproduce on its own. `Uri` percent-decodes
+path segments when parsing and re-encodes them with a keep-set that leaves 
sub-delims raw, so an encoded *pchar* does
+not survive the round trip — `%2B` is rendered back as `+`, for instance. 
Callers that must reproduce the target
+byte-for-byte should pass it through this header. AWS SigV4 is a typical case: 
the signature covers the encoded path,
+so an S3 object key containing `+` or `=` must reach the wire exactly as it 
was signed.
+
 ## HttpResponse
 
 An @apidoc[HttpResponse] consists of
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 5e301267e..c70f1d0db 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,6 +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.settings.ClientConnectionSettings
 import pekko.http.scaladsl.settings.ServerSettings
 import pekko.util.OptionVal
@@ -73,10 +74,21 @@ private[http2] class RequestRendering(
     headerPairs += ":method" -> request.method.value
     headerPairs += ":scheme" -> request.uri.scheme
     headerPairs += ":authority" -> request.uri.authority.toString
-    headerPairs += ":path" -> 
request.uri.toHttpRequestTargetOriginForm.toString
+    // 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
+    // byte-for-byte -- AWS SigV4 signs the encoded path, for instance -- pass 
it through this header.
+    headerPairs += ":path" -> rawRequestTarget(request).getOrElse(
+      request.uri.toHttpRequestTargetOriginForm.toString)
     headerPairs
   }
 
+  // `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: it is the
+  // caller's responsibility that it is a valid origin-form target.
+  private def rawRequestTarget(request: HttpRequest): Option[String] =
+    request.headers.collectFirst { case `Raw-Request-URI`(rawUri) => rawUri }
+
   override lazy val peerIdHeader: Option[(String, String)] =
     settings.userAgentHeader.map(h => h.lowercaseName -> h.value)
 
diff --git 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala
 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala
index d17c0185f..d4839f5f5 100644
--- 
a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala
+++ 
b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala
@@ -44,6 +44,7 @@ import pekko.http.scaladsl.model.headers.{
   `Cache-Control`,
   `Content-Length`,
   `Content-Type`,
+  `Raw-Request-URI`,
   RawHeader
 }
 import pekko.http.scaladsl.model.headers.CacheDirectives._
@@ -128,6 +129,41 @@ class Http2ClientSpec extends PekkoSpecWithMaterializer("""
           expectedResponse = HPackSpecExamples.FirstResponse)
       })
 
+      "send the Raw-Request-URI header verbatim as 
:path".inAssertAllStagesStopped(
+        new SimpleRequestResponseRoundtripSetup {
+          requestResponseRoundtrip(
+            streamId = 1,
+            // the `uri` is deliberately the lossy round-trip of the raw 
target, to show the header wins
+            request = HttpRequest(
+              uri = "https://www.example.com/a+b%20c";,
+              headers = List(`Raw-Request-URI`("/a%2Bb%20c"))),
+            expectedHeaders = defaultExpectedHeaders.map {
+              case (":path", _) => ":path" -> "/a%2Bb%20c"
+              case other        => other
+            },
+            response = Seq(
+              HeadersFrame(streamId = 1, endStream = true, endHeaders = true,
+                HPackSpecExamples.C61FirstResponseWithHuffman, None)),
+            expectedResponse = HPackSpecExamples.FirstResponse)
+        })
+
+      "re-encode the path from the Uri when no Raw-Request-URI header is 
present".inAssertAllStagesStopped(
+        new SimpleRequestResponseRoundtripSetup {
+          requestResponseRoundtrip(
+            streamId = 1,
+            request = HttpRequest(uri = "https://www.example.com/a%2Bb%20c";),
+            // `Uri` decodes `%2B` when parsing and renders `+` back, since 
`+` is kept raw by the pchar keep-set.
+            // This is the round-trip loss that makes the `Raw-Request-URI` 
escape hatch necessary.
+            expectedHeaders = defaultExpectedHeaders.map {
+              case (":path", _) => ":path" -> "/a+b%20c"
+              case other        => other
+            },
+            response = Seq(
+              HeadersFrame(streamId = 1, endStream = true, endHeaders = true,
+                HPackSpecExamples.C61FirstResponseWithHuffman, None)),
+            expectedResponse = HPackSpecExamples.FirstResponse)
+        })
+
       "GOAWAY when the response has an invalid headers 
frame".inAssertAllStagesStopped(new TestSetup with NetProbes {
         val streamId = 0x1
         user.emitRequest(HttpRequest(uri = "http://www.example.com/";))


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

Reply via email to