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 e697e5416 fix: reject a request-line part that cannot be sent as given 
(#1296)
e697e5416 is described below

commit e697e5416bc52e072628d97288e9694911d265f9
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 18 09:37:07 2026 +0100

    fix: reject a request-line part that cannot be sent as given (#1296)
    
    * Reject a Raw-Request-URI that cannot be sent as a request target
    
    Motivation:
    `Raw-Request-URI` is the escape hatch for sending a request target
    verbatim, and the HTTP/1.1 renderer honours that literally: it writes the
    value into the request line with `r ~~ rawUri`, with no check on what it
    contains. Two things go wrong with an unchecked value. A space, CR or LF
    ends the target early and lets whatever follows be read as the protocol,
    a header or a second request, so an application that builds the header
    from input it did not validate has a request-line injection. And the
    renderer writes a String one char at a time truncated to a byte, so a
    character outside ASCII does not arrive as itself: U+010D lands on the
    wire as 0x0D, a CR, without the value ever containing one.
    
    Over HTTP/2 (#1280) the same value becomes the `:path` pseudo-header,
    where the HPACK guard drops a field carrying CR, LF or NUL -- a malformed
    request rather than an injection, but a silent one.
    
    Modification:
    Validate on construction, following `Referer`: the value must be
    non-empty and consist of visible ASCII only (0x21-0x7E), which is what a
    request target may contain on the wire (RFC 9112 section 3.2) and the
    only thing the renderer can send faithfully. Anything else fails with an
    `IllegalArgumentException` naming the character and its index. `copy`
    and the Java `RawRequestURI.create` go through the same constructor. The
    server-side creation under `raw-request-uri-header = on` is unaffected:
    it builds the header from a target the URI parser has already accepted,
    which is visible ASCII by construction.
    
    Document the constraint in the scaladoc and in the `Raw-Request-URI`
    section of the model docs, together with the warning not to build the
    header from unvalidated request input.
    
    Result:
    A request target that would corrupt the request line, or that the
    renderer would corrupt, is rejected where the application creates it,
    instead of being sent.
    
    Tests:
    - `HeaderSpec` gains two cases: the full visible-ASCII range and the
      existing `%80%fe%ff` value are accepted; space, CR+LF, LF, tab, NUL,
      DEL, two non-ASCII characters, the empty string and a `copy` are each
      rejected, and the message names the offending character. The rejecting
      case fails with the validation reverted.
    - sbt "http-core/testOnly ...HeaderSpec ...RequestRendererSpec
      ...RequestParserCRLFSpec ...NewConnectionPoolSpec" - 133 pass.
    - sbt "http2-tests/testOnly ...Http2ClientSpec" (Raw-Request-URI cases),
      "docs/testOnly ...ModelSpec ...ModelDocTest" - pass.
    - sbt "http-core/mimaReportBinaryIssues" - pass.
    - native scalafmt clean.
    
    References:
    Refs #1280
    
    * Require a custom HTTP method name to be a token
    
    Motivation:
    The request line has two application-supplied parts, and the previous
    commit validated one of them. `HttpMethod.custom` checked only that the
    name was non-empty, and the HTTP/1.1 renderer writes it into the request
    line as given (`r ~~ method ~~ ' '`), so a name carrying a space, CR or
    LF ended the method early and let the rest be read as the target, the
    protocol or a header. Over HTTP/2 the same name becomes `:method`, where
    the HPACK guard drops a field carrying CR, LF or NUL and sends a
    malformed request.
    
    Modification:
    Require the name to be a token (RFC 9110 section 5.6.2) in both `custom`
    overloads that construct a method, using the parser's own `tchar` class;
    the single-argument overload and the Java `HttpMethods.custom` delegate
    to them. The server never constructs a method from wire bytes -- it looks
    the parsed token up among the registered custom methods -- so only
    application code is affected. Document the constraint at the custom
    method section of the model docs, and correct the HTTP/2 renderer's
    comment on `Raw-Request-URI`, which still said the value was taken on
    trust.
    
    Result:
    Neither part of the request line an application can supply can carry a
    character that would corrupt it, and neither can reach the HPACK guard's
    silent drop of a pseudo-header.
    
    Tests:
    - `HttpMethodsSpec` gains two cases: every tchar and two real-world
      custom methods are accepted; a request-line injection, CR+LF, tab, NUL,
      DEL, `/`, `:`, a non-ASCII character, the empty string and the
      five-argument overload are each rejected, and the message names the
      offending character. The rejecting case fails with the validation
      reverted.
    - sbt "http-core/testOnly ...HttpMethodsSpec ...HeaderSpec
      ...RequestParserCRLFSpec ...HttpHeaderSpec ...RequestRendererSpec"
      - 191 pass (the last three register custom methods).
    - sbt "http-core/mimaReportBinaryIssues" - pass.
    - native scalafmt clean.
    
    References:
    Refs #1280
---
 docs/src/main/paradox/common/http-model.md         | 14 +++++++++++
 .../impl/engine/http2/HttpMessageRendering.scala   |  5 ++--
 .../pekko/http/scaladsl/model/HttpMethod.scala     | 18 ++++++++++++--
 .../http/scaladsl/model/headers/headers.scala      | 29 +++++++++++++++++++++-
 .../http/scaladsl/model/HttpMethodsSpec.scala      | 28 +++++++++++++++++++++
 .../http/scaladsl/model/headers/HeaderSpec.scala   | 27 ++++++++++++++++++++
 6 files changed, 116 insertions(+), 5 deletions(-)

diff --git a/docs/src/main/paradox/common/http-model.md 
b/docs/src/main/paradox/common/http-model.md
index cd399bcf1..96878220b 100644
--- a/docs/src/main/paradox/common/http-model.md
+++ b/docs/src/main/paradox/common/http-model.md
@@ -107,6 +107,14 @@ The `Raw-Request-URI` header is honoured by both the 
HTTP/1.1 and the HTTP/2 cli
 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.
 
+Because the value goes to the wire as it is, it has to be something that *can* 
be sent as a request target: non-empty,
+and made of visible ASCII characters only (`0x21`–`0x7E`). The header rejects 
anything else on construction with an
+`IllegalArgumentException` — a space, CR or LF would end the request line 
early and let whatever follows be read as
+the protocol, a header or a second request, and a character outside ASCII 
cannot be rendered faithfully at all.
+Percent-encode what the target needs to carry; the encoded form is sent 
untouched. Never build this header from
+request input you have not validated: it is the one place where the client 
sends bytes you hand it without parsing
+them first.
+
 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
@@ -474,3 +482,9 @@ Scala
 
 Java
 :   @@snip 
[CustomHttpMethodsExampleTest.java](/docs/src/test/java/docs/http/javadsl/server/directives/CustomHttpMethodExamplesTest.java)
 { #customHttpMethod }
+
+The name of a custom method must be a *token* (RFC 9110 §5.6.2): letters, 
digits and the characters
+`` !#$%&'*+-.^_`|~ ``. Anything else — a space, a control character, a 
delimiter such as `/` or `:`, a character
+outside ASCII — is rejected with an `IllegalArgumentException` when the method 
is created. The name is written into the
+request line as given, ahead of the request target, so a space, CR or LF in it 
would end the method early and let the
+rest be read as the target, the protocol or a header.
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 b9242a2af..5d999ebd5 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
@@ -84,8 +84,9 @@ private[http2] class RequestRendering(
   }
 
   // `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.
+  // `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
+  // the `:path` field for. Whether it is a well-formed origin-form target is 
still the caller's responsibility.
   private def rawRequestTarget(request: HttpRequest): Option[String] =
     request.headers.collectFirst { case `Raw-Request-URI`(rawUri) => rawUri }
 
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
index 128fbdd31..c54f9216d 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
@@ -16,6 +16,7 @@ package org.apache.pekko.http.scaladsl.model
 import java.util.Locale
 
 import org.apache.pekko
+import pekko.http.impl.model.parser.CharacterClasses
 import pekko.http.impl.util._
 import pekko.http.javadsl.{ model => jm }
 import pekko.http.scaladsl.model.RequestEntityAcceptance._
@@ -65,7 +66,7 @@ object HttpMethod {
   @deprecated("Use the overload with contentLengthAllowed parameter", since = 
"1.4.0")
   def custom(name: String, safe: Boolean, idempotent: Boolean, 
requestEntityAcceptance: RequestEntityAcceptance)
       : HttpMethod = {
-    require(name.nonEmpty, "value must be non-empty")
+    requireToken(name)
     require(!safe || idempotent, "An HTTP method cannot be safe without being 
idempotent")
     apply(name, safe, idempotent, requestEntityAcceptance, 
oldContentLengthCondition)
   }
@@ -75,7 +76,7 @@ object HttpMethod {
    */
   def custom(name: String, safe: Boolean, idempotent: Boolean, 
requestEntityAcceptance: RequestEntityAcceptance,
       contentLengthAllowed: Boolean): HttpMethod = {
-    require(name.nonEmpty, "value must be non-empty")
+    requireToken(name)
     require(!safe || idempotent, "An HTTP method cannot be safe without being 
idempotent")
     apply(name, safe, idempotent, requestEntityAcceptance, if 
(contentLengthAllowed) anyToTrue else anyToFalse)
   }
@@ -86,6 +87,19 @@ object HttpMethod {
    */
   def custom(name: String): HttpMethod =
     custom(name, safe = false, idempotent = false, requestEntityAcceptance = 
Expected, contentLengthAllowed = true)
+
+  // A method name is written into the HTTP/1.1 request line as given, ahead 
of the request target, so a space, CR
+  // or LF in it would end the method early and let the rest be read as the 
target, the protocol or a header. Only a
+  // token (RFC 9110 section 5.6.2) is a method on the wire, and only a token 
is accepted.
+  private def requireToken(name: String): Unit = {
+    require(name.nonEmpty, "value must be non-empty")
+    var ix = 0
+    while (ix < name.length && CharacterClasses.tchar(name.charAt(ix))) ix += 1
+    require(
+      ix == name.length,
+      "an HTTP method name must be a token (RFC 9110 section 5.6.2), it is 
written into the request line as given: " +
+      s"found U+${"%04X".format(name.charAt(ix).toInt)} at index $ix")
+  }
 }
 
 object HttpMethods extends ObjectRegistry[String, HttpMethod] {
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
index 17adc8486..97f2211f5 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/headers/headers.scala
@@ -826,8 +826,35 @@ object RawHeader {
     Some(customHeader.name -> customHeader.value)
 }
 
-object `Raw-Request-URI` extends ModeledCompanion[`Raw-Request-URI`]
+object `Raw-Request-URI` extends ModeledCompanion[`Raw-Request-URI`] {
+  // A request target goes to the wire exactly as given: into the HTTP/1.1 
request line, where a space, CR or LF ends
+  // the target early and lets whatever follows be read as the protocol, a 
header or a second request; and character
+  // by character truncated to a byte, so that a character outside ASCII lands 
as its low byte -- '\u010d' is sent
+  // as CR. Only visible ASCII (RFC 9112 section 3.2) can be rendered 
faithfully, so only visible ASCII is accepted.
+  private[http] def firstIllegalCharIndex(uri: String): Int = {
+    var ix = 0
+    while (ix < uri.length && { val c = uri.charAt(ix); c > ' ' && c < 
'\u007f' }) ix += 1
+    if (ix < uri.length) ix else -1
+  }
+}
+
+/**
+ * Carries a request target to send verbatim, in place of the one rendered 
from the request's `Uri`.
+ *
+ * The value is written to the wire as given, so it must be a request target 
that can be sent as it is: non-empty,
+ * and consisting of visible ASCII characters only (0x21-0x7E). Anything else 
-- a space, CR, LF, a control
+ * character, a character outside ASCII -- is rejected on construction rather 
than rendered into the request line.
+ * Percent-encode what the target has to carry; the encoded form is sent 
untouched.
+ */
 final case class `Raw-Request-URI`(uri: String) extends 
jm.headers.RawRequestURI with SyntheticHeader {
+  require(uri.nonEmpty, "Raw-Request-URI must not be empty")
+  require(
+    `Raw-Request-URI`.firstIllegalCharIndex(uri) < 0, {
+      val ix = `Raw-Request-URI`.firstIllegalCharIndex(uri)
+      "Raw-Request-URI may only contain visible ASCII characters (0x21-0x7E), 
the request target is sent to the " +
+      s"wire as given: found U+${"%04X".format(uri.charAt(ix).toInt)} at index 
$ix"
+    })
+
   def renderValue[R <: Rendering](r: R): r.type = r ~~ uri
   protected def companion = `Raw-Request-URI`
 }
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/HttpMethodsSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/HttpMethodsSpec.scala
index f60dd55cb..3e92cd86e 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/HttpMethodsSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/HttpMethodsSpec.scala
@@ -37,6 +37,34 @@ class HttpMethodsSpec extends AnyWordSpec {
     }
   }
 
+  "HttpMethod.custom" must {
+    "accept any token" in {
+      assert(HttpMethod.custom("PROPFIND").value == "PROPFIND")
+      assert(HttpMethod.custom("M-SEARCH").value == "M-SEARCH")
+      // every tchar: ALPHA, DIGIT and the sixteen special characters RFC 9110 
allows in a token
+      val allTchars = ('A' to 'Z').mkString + ('a' to 'z').mkString + ('0' to 
'9').mkString + "!#$%&'*+-.^_`|~"
+      assert(HttpMethod.custom(allTchars).value == allTchars)
+    }
+    "reject a name that is not a token" in {
+      // the name is written into the request line as given, ahead of the 
request target
+      val e = intercept[IllegalArgumentException](
+        HttpMethod.custom("GET /admin HTTP/1.1\u000d\u000aX-Injected: 
1\u000d\u000aFOO"))
+      assert(e.getMessage.contains("found U+0020 at index 3"))
+      intercept[IllegalArgumentException](HttpMethod.custom("GET\u000d\u000a"))
+      intercept[IllegalArgumentException](HttpMethod.custom("GET\u0009"))
+      intercept[IllegalArgumentException](HttpMethod.custom("GET\u0000"))
+      intercept[IllegalArgumentException](HttpMethod.custom("GET\u007f"))
+      // delimiters are visible ASCII but not tchar
+      intercept[IllegalArgumentException](HttpMethod.custom("GET/"))
+      intercept[IllegalArgumentException](HttpMethod.custom("GET:"))
+      intercept[IllegalArgumentException](HttpMethod.custom("G\u010dT"))
+      intercept[IllegalArgumentException](HttpMethod.custom(""))
+      // every overload validates
+      intercept[IllegalArgumentException](HttpMethod.custom("GET ", safe = 
false, idempotent = false,
+        requestEntityAcceptance = RequestEntityAcceptance.Expected, 
contentLengthAllowed = true))
+    }
+  }
+
   "HttpMethods.QUERY" must {
     "be safe per RFC 10008" in {
       assert(HttpMethods.QUERY.isSafe)
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
index 9527fb9d2..4050705d5 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/scaladsl/model/headers/HeaderSpec.scala
@@ -254,4 +254,31 @@ class HeaderSpec extends AnyFreeSpec with Matchers {
       }
     }
   }
+  "Raw-Request-URI should" - {
+    "accept any request target made of visible ASCII" in {
+      `Raw-Request-URI`("/def%80%fe%ff").uri shouldEqual "/def%80%fe%ff"
+      `Raw-Request-URI`("/a+b=c?d=e&f=%2B#g").uri shouldEqual 
"/a+b=c?d=e&f=%2B#g"
+      // every visible ASCII character, 0x21 to 0x7E
+      val allVisible = (0x21 to 0x7E).map(_.toChar).mkString
+      `Raw-Request-URI`(allVisible).uri shouldEqual allVisible
+    }
+    "reject anything that cannot be sent as a request target" in {
+      // the value is written into the request line as given: a space, CR or 
LF would end the target early and let
+      // what follows be read as the protocol, a header or a second request
+      val e = the[IllegalArgumentException] thrownBy `Raw-Request-URI`("/a 
HTTP/1.0")
+      e.getMessage should include("found U+0020 at index 2")
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a\u000d\u000aHost: evil")
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a\u000aX: y")
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a\u0009b")
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a\u0000b")
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a\u007fb")
+      // rendered a character at a time truncated to a byte, so a character 
outside ASCII is not sent as itself:
+      // U+010D would land on the wire as 0x0D, a CR
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a\u010d")
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/caf\u00e9")
+      an[IllegalArgumentException] should be thrownBy `Raw-Request-URI`("")
+      // `copy` goes through the constructor too
+      an[IllegalArgumentException] should be thrownBy 
`Raw-Request-URI`("/a").copy(uri = "/a\u000d\u000a")
+    }
+  }
 }


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

Reply via email to