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]