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 b9a6a41ea fix: prevent CRLF injection through chunk trailers and 
extensions (#1256)
b9a6a41ea is described below

commit b9a6a41ea13440910137ad3807a1c44f3f2bbc3a
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 1 20:55:14 2026 +0100

    fix: prevent CRLF injection through chunk trailers and extensions (#1256)
    
    Motivation:
    `RenderSupport.renderChunk` emitted two attacker-influenced parts of a
    chunked response without the CR/LF guard that the main header block
    relies on:
    
    - Trailer headers were rendered with `r ~~ trailer`, which resolves to
      the generic sequence renderer and calls `header.render` directly,
      bypassing the `~~(HttpHeader)` overload whose `check` scans the
      rendered bytes for CR/LF and drops the header. `HttpEntity.LastChunk`
      and `RawHeader` do no CR/LF validation, so a trailer built from user
      data (e.g. `RawHeader("X-Trace", "ok\r\nSet-Cookie: ..."))`) could
      split the response. The identical header placed in the main header
      block is caught; only the trailer path let it through.
    - The chunk extension was rendered raw into the chunk-size line
      (`r ~~ ';' ~~ extension`) with no CR/LF check, so a CR/LF in an
      app-set extension corrupted the chunk framing.
    
    Modification:
    Render each trailer header through the guarded `~~(HttpHeader)`
    overload (`trailer.foreach(r ~~ _)`), matching the main header block,
    and remove the now-unused `trailerRenderer` implicit so the unguarded
    path cannot be reintroduced by accident. Skip a chunk extension that
    contains CR/LF; the extension is optional metadata, so omitting an
    illegal one is safe. Byte output is unchanged for valid trailers and
    extensions.
    
    Result:
    CR/LF in a chunk trailer header value or a chunk extension can no
    longer reach the wire; the offending header/extension is dropped, as
    in the main header block, instead of splitting the response.
    
    Tests:
    - sbt "http-core/testOnly 
org.apache.pekko.http.impl.engine.rendering.ResponseRendererSpec 
org.apache.pekko.http.impl.engine.rendering.RequestRendererSpec" - pass (62 
tests); two new tests assert a CRLF-bearing trailer header and a CRLF-bearing 
chunk extension are dropped. Verified both fail with the fix stashed (the 
injected bytes reach the output).
    - sbt http-core/mimaReportBinaryIssues - pass (internal 
impl.engine.rendering change, no public API).
    
    References:
    None - closes the CRLF-injection paths in chunked response rendering
---
 .../http/impl/engine/rendering/RenderSupport.scala | 15 ++++----
 .../engine/rendering/ResponseRendererSpec.scala    | 44 ++++++++++++++++++++++
 2 files changed, 52 insertions(+), 7 deletions(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/RenderSupport.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/RenderSupport.scala
index 7cc92d802..cfd7c6e49 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/RenderSupport.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/RenderSupport.scala
@@ -29,8 +29,6 @@ import pekko.stream.stage.GraphStage
 import pekko.stream._
 import pekko.stream.scaladsl.{ Flow, Sink, Source }
 
-import scala.collection.immutable
-
 /**
  * INTERNAL API
  */
@@ -53,9 +51,6 @@ private[http] object RenderSupport {
   private val TextHtmlContentType = preRenderContentType(`text/html(UTF-8)`)
   private val TextCsvContentType = preRenderContentType(`text/csv(UTF-8)`)
 
-  implicit val trailerRenderer: Renderer[immutable.Iterable[HttpHeader]] =
-    Renderer.genericSeqRenderer[Renderable, HttpHeader](Rendering.CrLf, 
Rendering.Empty)
-
   val defaultLastChunkBytes: ByteString = renderChunk(HttpEntity.LastChunk)
 
   def CancelSecond[T, Mat](first: Source[T, Mat], second: Source[T, Any]): 
Source[T, Mat] =
@@ -156,12 +151,18 @@ private[http] object RenderSupport {
       2 + 2
     val r = new ByteStringRendering(renderedSize)
     r ~~% data.length
-    if (extension.nonEmpty) r ~~ ';' ~~ extension
+    // drop an extension carrying CR/LF: it is rendered raw into the 
chunk-size line, so a CR/LF would break the chunk
+    // framing / split the response. The extension is optional metadata, so 
omitting an illegal one is safe
+    if (extension.nonEmpty && extension.indexOf('\r') < 0 && 
extension.indexOf('\n') < 0) r ~~ ';' ~~ extension
     r ~~ CrLf
     chunk match {
       case HttpEntity.Chunk(data, _)        => r ~~ data
       case HttpEntity.LastChunk(_, Nil)     => // nothing to do
-      case HttpEntity.LastChunk(_, trailer) => r ~~ trailer ~~ CrLf
+      case HttpEntity.LastChunk(_, trailer) =>
+        // render each trailer header through the `~~(HttpHeader)` overload, 
which scans the rendered bytes for CR/LF
+        // and drops the header if it finds them; the generic sequence 
renderer would call `header.render` directly
+        // and let an attacker-controlled trailer value (e.g. a RawHeader) 
inject CRLF and split the response
+        trailer.foreach(r ~~ _)
     }
     r ~~ CrLf
     r.get
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
index 700947391..06df8cab6 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
@@ -401,6 +401,50 @@ class ResponseRendererSpec extends AnyFreeSpec with 
Matchers with BeforeAndAfter
         }
       }
 
+      "dropping a chunk trailer header whose value would inject CRLF into the 
response" in new TestSetup() {
+        // the trailer carries an attacker-controlled RawHeader value 
containing CRLF; it must be discarded rather
+        // than split the response, exactly as a header in the main header 
block is
+        HttpResponse(entity = Chunked(
+          ContentTypes.`text/plain(UTF-8)`,
+          source(
+            Chunk(ByteString("body123")),
+            LastChunk("", List(RawHeader("X-Trace", "ok\r\nSet-Cookie: 
injected=1"), Age(30)))))) should renderTo {
+          """HTTP/1.1 200 OK
+            |Server: pekko-http/1.0.0
+            |Date: Thu, 25 Aug 2011 09:10:29 GMT
+            |Transfer-Encoding: chunked
+            |Content-Type: text/plain; charset=UTF-8
+            |
+            |7
+            |body123
+            |0
+            |Age: 30
+            |
+            |"""
+        }
+      }
+
+      "dropping a chunk extension whose value would inject CRLF into the chunk 
framing" in new TestSetup() {
+        // the chunk extension is rendered raw into the chunk-size line; a 
CRLF in it must not corrupt the framing
+        HttpResponse(entity = Chunked(
+          ContentTypes.`text/plain(UTF-8)`,
+          source(
+            Chunk(ByteString("body123"), "ok\r\nSet-Cookie: injected=1"),
+            LastChunk))) should renderTo {
+          """HTTP/1.1 200 OK
+            |Server: pekko-http/1.0.0
+            |Date: Thu, 25 Aug 2011 09:10:29 GMT
+            |Transfer-Encoding: chunked
+            |Content-Type: text/plain; charset=UTF-8
+            |
+            |7
+            |body123
+            |0
+            |
+            |"""
+        }
+      }
+
       "with one chunk and and extra LastChunks at the end (which should be 
ignored)" in new TestSetup() {
         HttpResponse(entity = Chunked(
           ContentTypes.`text/plain(UTF-8)`,


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

Reply via email to