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 6cd9e7b80 fix: drop rendered headers containing NUL as well as CR and 
LF (#1260)
6cd9e7b80 is described below

commit 6cd9e7b80dd3c8eb23deeb49e36b2b9c0f82a912
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Sep 2 09:22:53 2026 +0100

    fix: drop rendered headers containing NUL as well as CR and LF (#1260)
    
    Motivation:
    The guard in `Rendering.~~(HttpHeader)` renders a header and then scans
    the rendered bytes, discarding the header if it finds one of the
    characters that must never reach the wire. It only looked for CR and
    LF, so a `RawHeader` value carrying a NUL was rendered as-is. NUL is
    not a legal field-value character, and a downstream consumer that
    treats the value as a C string truncates it there, so two parties can
    disagree about where the value ends. The HTTP/2 renderer rejects CR, LF
    and NUL alike, so HTTP/1.1 was the weaker of the two.
    
    Modification:
    Add `Rendering.isIllegalHeaderChar`, which covers CR, LF and NUL, and
    use it from all four `check` implementations (`StringRendering`,
    `ByteArrayRendering`, `ByteStringRendering` and
    `CustomCharsetByteStringRendering`) instead of repeating the character
    comparison a fourth time. It takes an `Int` so the `Char` and `Byte`
    based renderings can pass their element straight in.
    
    Result:
    A header whose name or value contains NUL is discarded like one
    containing CR or LF, on every rendering implementation, and the rule
    now lives in one place.
    
    Tests:
    - sbt "http-core/testOnly org.apache.pekko.http.impl.util.RenderingSpec 
org.apache.pekko.http.impl.engine.rendering.ResponseRendererSpec 
org.apache.pekko.http.impl.engine.rendering.RequestRendererSpec" - pass (80 
tests); a new case in the shared rendering table asserts a header with NUL in 
the value is discarded. Verified it fails with the fix stashed, once for each 
of the four renderings.
    - sbt http-core/mimaReportBinaryIssues - pass (internal impl.util change, 
no public API).
    
    References:
    None - extends the outgoing header guard to NUL
---
 .../scala/org/apache/pekko/http/impl/util/Rendering.scala | 15 +++++++++++----
 .../org/apache/pekko/http/impl/util/RenderingSpec.scala   | 11 +++++++++++
 2 files changed, 22 insertions(+), 4 deletions(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/util/Rendering.scala 
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/Rendering.scala
index 0547fbd23..e40b448b1 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/util/Rendering.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/util/Rendering.scala
@@ -262,6 +262,13 @@ private[http] object Rendering {
   val floatFormat = new DecimalFormat("0.0##", 
DecimalFormatSymbols.getInstance(Locale.ROOT))
   val `\"` = CharPredicate('\\', '"')
 
+  /**
+   * Characters that must never reach the wire inside a rendered header: CR 
and LF would split the message, and NUL
+   * is not a legal field-value character and can truncate the value in a 
consumer that treats it as a C string.
+   * Takes an `Int` so that both the `Char` and the `Byte` based renderings 
can pass their element in directly.
+   */
+  def isIllegalHeaderChar(ch: Int): Boolean = ch == '\r' || ch == '\n' || ch 
== 0
+
   // US-ASCII printable chars except for '"' and escape chars '\' and (for 
faulty clients) '%'
   // https://tools.ietf.org/html/rfc6266#appendix-D
   val contentDispositionFilenameSafeChars = CharPredicate.Printable -- "%\"\\"
@@ -299,7 +306,7 @@ private[http] class StringRendering extends Rendering {
     @tailrec def rec(mark: Int): Boolean =
       if (mark < sb.length()) {
         val ch = sb.charAt(mark)
-        if (ch == '\r' || ch == '\n') {
+        if (Rendering.isIllegalHeaderChar(ch)) {
           sb.delete(origMark, sb.length())
           false
         } else rec(mark + 1)
@@ -369,7 +376,7 @@ private[http] class ByteArrayRendering(sizeHint: Int, 
logDiscardedHeader: String
 
     @tailrec def rec(mark: Int): Boolean =
       if (mark < size) {
-        if (array(mark) == '\r' || array(mark) == '\n') {
+        if (Rendering.isIllegalHeaderChar(array(mark))) {
           logDiscardedHeader("Invalid outgoing header was discarded. " + 
LogByteStringTools.printByteString(
             ByteString.fromArray(array, origMark, size - origMark)))
           size = origMark
@@ -416,7 +423,7 @@ private[http] class ByteStringRendering(sizeHint: Int, 
logDiscardedHeader: Strin
     @tailrec def rec(mark: Int): Boolean =
       if (mark < builder.length) {
         val ch = contents(mark)
-        if (ch == '\r' || ch == '\n') {
+        if (Rendering.isIllegalHeaderChar(ch)) {
           logDiscardedHeader(
             "Invalid outgoing header was discarded. " + 
LogByteStringTools.printByteString(contents.drop(origMark)))
           builder.clear()
@@ -491,7 +498,7 @@ private[http] class 
CustomCharsetByteStringRendering(nioCharset: Charset, sizeHi
     @tailrec def rec(mark: Int): Boolean =
       if (mark < builder.length) {
         val ch = contents(mark)
-        if (ch == '\r' || ch == '\n') {
+        if (Rendering.isIllegalHeaderChar(ch)) {
           builder.clear()
           builder.append(contents.take(origMark))
           false
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/RenderingSpec.scala 
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/RenderingSpec.scala
index 94312d5cc..2af2c0901 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/RenderingSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/RenderingSpec.scala
@@ -98,6 +98,17 @@ class RenderingSpec extends PekkoSpecWithMaterializer with 
Matchers {
 
           rendered shouldBe ""
         }
+        // NUL is not a legal field-value character and can truncate the value 
in a consumer that treats it as a
+        // C string, so it must not reach the wire either
+        "do not render header with NUL in the value" in {
+          val r = setup.create()
+          val rendered =
+            EventFilter.warning(pattern = "Invalid outgoing header was 
discarded").intercept {
+              setup.result(r ~~ RawHeader("Test", "broken\u0000value"))
+            }
+
+          rendered shouldBe ""
+        }
       }
     }
   }


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

Reply via email to