VYPR
High severityNVD Advisory· Published Sep 21, 2021· Updated Aug 4, 2024

Response Splitting from unsanitized headers in http4s

CVE-2021-41084

Description

http4s is vulnerable to HTTP request/response splitting attacks via unsanitized user input in headers, status phrases, URIs, and paths.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

http4s is vulnerable to HTTP request/response splitting attacks via unsanitized user input in headers, status phrases, URIs, and paths.

Vulnerability

http4s, an open source Scala HTTP interface, is vulnerable to request-splitting and response-splitting attacks when untrusted user input is used to construct Header.name, Header.value, Status.reason, Uri.Path, or URI.RegName (through version 0.21). The vulnerability affects versions prior to 0.21.30, 0.22.5, 0.23.4, and 1.0.0-M27. All backends (blaze, ember, and jetty) are affected to varying degrees; for example, blaze-server and ember-server are vulnerable for header values, status reasons, and URI fields [2][4].

Exploitation

An attacker with the ability to supply arbitrary input to any of the vulnerable fields (e.g., via a query parameter, form field, or header value) can inject carriage return (%0d), newline (%0a), or null characters to break out of the intended HTTP message structure. Public proof-of-concept shows that passing a header value containing %0d%0a followed by crafted HTTP headers and a body allows the attacker to control subsequent parts of the response, effectively hijacking the HTTP response [4]. No special network position or authentication is required beyond being able to send requests to an http4s service that reflects user input into one of the unprotected fields.

Impact

On successful exploitation, the attacker can perform HTTP response splitting or request splitting, leading to cache poisoning, content injection, session hijacking, or cross-site scripting (XSS) in downstream clients or proxies. The attacker can inject arbitrary status lines, headers, or body content into the HTTP response stream, achieving a wide range of manipulation of the HTTP conversation [2][4].

Mitigation

http4s versions 0.21.30, 0.22.5, 0.23.4, and 1.0.0-M27 contain fixes that properly sanitize or reject carriage return, newline, and null characters in the affected fields [2][4]. Users should upgrade to the latest available patched version. As a general best practice, applications should sanitize any user input before inserting it into header names, header values, status reason phrases, URI paths, or URI authority registered names [2]. No workaround other than upgrading is documented.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.http4s:http4s-client_2.12Maven
< 0.21.290.21.29
org.http4s:http4s-client_2.12Maven
>= 0.22.0, < 0.22.50.22.5
org.http4s:http4s-client_2.12Maven
>= 0.23.0, < 0.23.40.23.4
org.http4s:http4s-client_2.13Maven
< 0.21.290.21.29
org.http4s:http4s-client_2.13Maven
>= 0.22.0, < 0.22.50.22.5
org.http4s:http4s-client_2.13Maven
>= 0.23.0, < 0.23.40.23.4
org.http4s:http4s-client_3Maven
< 0.21.290.21.29
org.http4s:http4s-client_3Maven
>= 0.22.0, < 0.22.50.22.5
org.http4s:http4s-client_3Maven
>= 0.23.0, < 0.23.40.23.4
org.http4s:http4s-server_2.10Maven
<= 0.21.28
org.http4s:http4s-server_2.11Maven
<= 0.21.28
org.http4s:http4s-server_2.12Maven
< 0.21.290.21.29
org.http4s:http4s-server_2.12Maven
>= 0.22.0, < 0.22.50.22.5
org.http4s:http4s-server_2.12Maven
>= 0.23.0, < 0.23.40.23.4
org.http4s:http4s-server_2.13Maven
< 0.21.290.21.29
org.http4s:http4s-server_2.13Maven
>= 0.22.0, < 0.22.50.22.5
org.http4s:http4s-server_2.13Maven
>= 0.23.0, < 0.23.40.23.4
org.http4s:http4s-server_2.13.0-M5Maven
<= 0.21.28
org.http4s:http4s-server_3Maven
>= 0.22.0, < 0.22.50.22.5
org.http4s:http4s-server_3Maven
>= 0.23.0, < 0.23.40.23.4

Affected products

10

Patches

1
d02007db1da4

Merge pull request from GHSA-5vcm-3xc3-w7x3

https://github.com/http4s/http4sRoss A. BakerSep 21, 2021via ghsa
23 files changed · +656 192
  • blaze-client/src/main/scala/org/http4s/blaze/client/Http1Connection.scala+5 0 modified
    @@ -33,6 +33,7 @@ import org.http4s.blazecore.{Http1Stage, IdleTimeoutStage}
     import org.http4s.blazecore.util.Http1Writer
     import org.http4s.client.RequestKey
     import org.http4s.headers.{Connection, Host, `Content-Length`, `User-Agent`}
    +import org.http4s.internal.CharPredicate
     import org.http4s.util.{StringWriter, Writer}
     import org.typelevel.vault._
     import scala.annotation.tailrec
    @@ -432,6 +433,8 @@ private final class Http1Connection[F[_]](
           else
             Left(new IllegalArgumentException("Host header required for HTTP/1.1 request"))
         else if (req.uri.path == Uri.Path.empty) Right(req.withUri(req.uri.copy(path = Uri.Path.Root)))
    +    else if (req.uri.path.renderString.exists(ForbiddenUriCharacters))
    +      Left(new IllegalArgumentException(s"Invalid URI path: ${req.uri.path}"))
         else Right(req) // All appears to be well
       }
     
    @@ -473,4 +476,6 @@ private object Http1Connection {
           writer
         } else writer
       }
    +
    +  private val ForbiddenUriCharacters = CharPredicate(0x0.toChar, ' ', '\r', '\n')
     }
    
  • blaze-core/src/main/scala/org/http4s/blazecore/Http1Stage.scala+6 4 modified
    @@ -293,17 +293,19 @@ object Http1Stage {
           Future.successful(buffer)
         } else CachedEmptyBufferThunk
     
    -  /** Encodes the headers into the Writer. Does not encode `Transfer-Encoding` or
    -    * `Content-Length` headers, which are left for the body encoder. Adds
    -    * `Date` header if one is missing and this is a server response.
    +  /** Encodes the headers into the Writer. Does not encode
    +    * `Transfer-Encoding` or `Content-Length` headers, which are left
    +    * for the body encoder. Does not encode headers with invalid
    +    * names. Adds `Date` header if one is missing and this is a server
    +    * response.
         *
         * Note: this method is very niche but useful for both server and client.
         */
       def encodeHeaders(headers: Iterable[Header.Raw], rr: Writer, isServer: Boolean): Unit = {
         var dateEncoded = false
         val dateName = Header[Date].name
         headers.foreach { h =>
    -      if (h.name != `Transfer-Encoding`.name && h.name != `Content-Length`.name) {
    +      if (h.name != `Transfer-Encoding`.name && h.name != `Content-Length`.name && h.isNameValid) {
             if (isServer && h.name == dateName) dateEncoded = true
             rr << h << "\r\n"
           }
    
  • blaze-server/src/main/scala/org/http4s/blaze/server/Http1ServerStage.scala+1 1 modified
    @@ -222,7 +222,7 @@ private[blaze] class Http1ServerStage[F[_]](
           resp: Response[F],
           bodyCleanup: () => Future[ByteBuffer]): Unit = {
         val rr = new StringWriter(512)
    -    rr << req.httpVersion << ' ' << resp.status.code << ' ' << resp.status.reason << "\r\n"
    +    rr << req.httpVersion << ' ' << resp.status << "\r\n"
     
         Http1Stage.encodeHeaders(resp.headers.headers, rr, isServer = true)
     
    
  • blaze-server/src/test/scala/org/http4s/blaze/server/Http1ServerStageSpec.scala+75 31 modified
    @@ -19,7 +19,7 @@ package blaze
     package server
     
     import cats.data.Kleisli
    -import cats.syntax.eq._
    +import cats.syntax.all._
     import cats.effect._
     import cats.effect.kernel.Deferred
     import cats.effect.std.Dispatcher
    @@ -535,35 +535,79 @@ class Http1ServerStageSpec extends Http4sSuite {
         }
       }
     
    -  fixture.test("Http1ServerStage: don't deadlock TickWheelExecutor with uncancelable request") {
    -    tw =>
    -      val reqUncancelable = List("GET /uncancelable HTTP/1.0\r\n\r\n")
    -      val reqCancelable = List("GET /cancelable HTTP/1.0\r\n\r\n")
    -
    -      (for {
    -        uncancelableStarted <- Deferred[IO, Unit]
    -        uncancelableCanceled <- Deferred[IO, Unit]
    -        cancelableStarted <- Deferred[IO, Unit]
    -        cancelableCanceled <- Deferred[IO, Unit]
    -        app = HttpApp[IO] {
    -          case req if req.pathInfo === path"/uncancelable" =>
    -            uncancelableStarted.complete(()) *>
    -              IO.uncancelable { poll =>
    -                poll(uncancelableCanceled.complete(())) *>
    -                  cancelableCanceled.get
    -              }.as(Response[IO]())
    -          case _ =>
    -            cancelableStarted.complete(()) *> IO.never.guarantee(
    -              cancelableCanceled.complete(()).void)
    -        }
    -        head <- IO(runRequest(tw, reqUncancelable, app))
    -        _ <- uncancelableStarted.get
    -        _ <- uncancelableCanceled.get
    -        _ <- IO(head.sendInboundCommand(Disconnected))
    -        head2 <- IO(runRequest(tw, reqCancelable, app))
    -        _ <- cancelableStarted.get
    -        _ <- IO(head2.sendInboundCommand(Disconnected))
    -        _ <- cancelableCanceled.get
    -      } yield ()).assert
    +  fixture.test("Prevent response splitting attacks on status reason phrase") { tw =>
    +    val rawReq = "GET /?reason=%0D%0AEvil:true%0D%0A HTTP/1.0\r\n\r\n"
    +    val head = runRequest(
    +      tw,
    +      List(rawReq),
    +      HttpApp { req =>
    +        Response[IO](Status.NoContent.withReason(req.params("reason"))).pure[IO]
    +      })
    +    head.result.map { buff =>
    +      val (_, headers, _) = ResponseParser.parseBuffer(buff)
    +      assertEquals(headers.find(_.name === ci"Evil"), None)
    +    }
    +  }
    +
    +  fixture.test("Prevent response splitting attacks on field name") { tw =>
    +    val rawReq = "GET /?fieldName=Fine:%0D%0AEvil:true%0D%0A HTTP/1.0\r\n\r\n"
    +    val head = runRequest(
    +      tw,
    +      List(rawReq),
    +      HttpApp { req =>
    +        Response[IO](Status.NoContent).putHeaders(req.params("fieldName") -> "oops").pure[IO]
    +      })
    +    head.result.map { buff =>
    +      val (_, headers, _) = ResponseParser.parseBuffer(buff)
    +      assertEquals(headers.find(_.name === ci"Evil"), None)
    +    }
    +  }
    +
    +  fixture.test("Prevent response splitting attacks on field value") { tw =>
    +    val rawReq = "GET /?fieldValue=%0D%0AEvil:true%0D%0A HTTP/1.0\r\n\r\n"
    +    val head = runRequest(
    +      tw,
    +      List(rawReq),
    +      HttpApp { req =>
    +        Response[IO](Status.NoContent)
    +          .putHeaders("X-Oops" -> req.params("fieldValue"))
    +          .pure[IO]
    +      })
    +    head.result.map { buff =>
    +      val (_, headers, _) = ResponseParser.parseBuffer(buff)
    +      assertEquals(headers.find(_.name === ci"Evil"), None)
    +    }
    +
    +    fixture.test("Http1ServerStage: don't deadlock TickWheelExecutor with uncancelable request") {
    +      tw =>
    +        val reqUncancelable = List("GET /uncancelable HTTP/1.0\r\n\r\n")
    +        val reqCancelable = List("GET /cancelable HTTP/1.0\r\n\r\n")
    +
    +        (for {
    +          uncancelableStarted <- Deferred[IO, Unit]
    +          uncancelableCanceled <- Deferred[IO, Unit]
    +          cancelableStarted <- Deferred[IO, Unit]
    +          cancelableCanceled <- Deferred[IO, Unit]
    +          app = HttpApp[IO] {
    +            case req if req.pathInfo === path"/uncancelable" =>
    +              uncancelableStarted.complete(()) *>
    +                IO.uncancelable { poll =>
    +                  poll(uncancelableCanceled.complete(())) *>
    +                    cancelableCanceled.get
    +                }.as(Response[IO]())
    +            case _ =>
    +              cancelableStarted.complete(()) *> IO.never.guarantee(
    +                cancelableCanceled.complete(()).void)
    +          }
    +          head <- IO(runRequest(tw, reqUncancelable, app))
    +          _ <- uncancelableStarted.get
    +          _ <- uncancelableCanceled.get
    +          _ <- IO(head.sendInboundCommand(Disconnected))
    +          head2 <- IO(runRequest(tw, reqCancelable, app))
    +          _ <- cancelableStarted.get
    +          _ <- IO(head2.sendInboundCommand(Disconnected))
    +          _ <- cancelableCanceled.get
    +        } yield ()).assert
    +    }
       }
     }
    
  • build.sbt+0 10 modified
    @@ -448,12 +448,6 @@ lazy val emberServer = libraryProject("ember-server", CrossType.Full, List(JVMPl
           log4catsSlf4j.value,
           javaWebSocket % Test
         ),
    -    mimaBinaryIssueFilters ++= Seq(
    -      ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.server.EmberServerBuilder#Defaults.maxConcurrency"),
    -      ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.server.internal.ServerHelpers.isKeepAlive"),
    -      ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.server.EmberServerBuilder#Defaults.maxConcurrency"),
    -      ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.http4s.ember.server.internal.ServerHelpers.runApp")
    -    ),
         Test / parallelExecution := false
       )
       .jsSettings(
    @@ -475,9 +469,6 @@ lazy val emberClient = libraryProject("ember-client", CrossType.Full, List(JVMPl
         description := "ember implementation for http4s clients",
         startYear := Some(2019),
         libraryDependencies += keypool.value,
    -    mimaBinaryIssueFilters := Seq(
    -      ProblemFilters.exclude[DirectMissingMethodProblem]("org.http4s.ember.client.EmberClientBuilder.this")
    -    )
       )
       .jvmSettings(libraryDependencies += log4catsSlf4j.value)
       .jsSettings(
    @@ -508,7 +499,6 @@ lazy val blazeClient = libraryProject("blaze-client")
       .settings(
         description := "blaze implementation for http4s clients",
         startYear := Some(2014),
    -    mimaBinaryIssueFilters ++= Seq()
       )
       .dependsOn(blazeCore % "compile;test->test", client % "compile;test->test")
     
    
  • client/shared/src/test/scala/org/http4s/client/ClientRouteTestBattery.scala+53 2 modified
    @@ -19,15 +19,16 @@ package client
     
     import cats.effect._
     import cats.syntax.all._
    -import com.comcast.ip4s.Host
    -import com.comcast.ip4s.SocketAddress
    +import com.comcast.ip4s.{Host, SocketAddress}
     import fs2._
     import org.http4s.client.dsl.Http4sClientDsl
     import org.http4s.client.testroutes.GetRoutes
     import org.http4s.dsl.io._
    +import org.http4s.implicits._
     import org.http4s.multipart.Multipart
     import org.http4s.multipart.Part
     import org.http4s.server.Server
    +import org.typelevel.ci._
     
     import java.util.Arrays
     import java.util.Locale
    @@ -117,6 +118,56 @@ abstract class ClientRouteTestBattery(name: String) extends Http4sSuite with Htt
         }
       }
     
    +  test("Mitigates request splitting attack in URI path") {
    +    for {
    +      uri <- url("/").map(
    +        _.withPath(Uri.Path(Vector(Uri.Path.Segment.encoded(
    +          "request-splitting HTTP/1.0\r\nEvil:true\r\nHide-Protocol-Version:")))))
    +      req = Request[IO](uri = uri)
    +      c <- client()
    +      status <- c.status(req).handleError(_ => Status.Ok)
    +    } yield assertEquals(status, Status.Ok)
    +  }
    +
    +  test("Mitigates request splitting attack in URI RegName") {
    +    for {
    +      srv <- server()
    +      address = srv.address
    +      hostname = address.host.toString
    +      port = address.port.value
    +      req = Request[IO](
    +        uri = Uri(
    +          authority = Uri
    +            .Authority(None, Uri.RegName(s"${hostname}\r\nEvil:true\r\n"), port = port.some)
    +            .some,
    +          path = path"/request-splitting"))
    +      c <- client()
    +      status <- c.status(req).handleError(_ => Status.Ok)
    +    } yield assertEquals(status, Status.Ok)
    +  }
    +
    +  test("Mitigates request splitting attack in field name") {
    +
    +    for {
    +      srv <- server()
    +      uri <- url("/request-splitting")
    +      req = Request[IO](uri = uri)
    +        .putHeaders(Header.Raw(ci"Fine:\r\nEvil:true\r\n", "oops"))
    +      c <- client()
    +      status <- c.status(req).handleError(_ => Status.Ok)
    +    } yield assertEquals(status, Status.Ok)
    +  }
    +
    +  test("Mitigates request splitting attack in field value") {
    +    for {
    +      uri <- url("/request-splitting")
    +      req = Request[IO](uri = uri)
    +        .putHeaders(Header.Raw(ci"X-Carrier", "\r\nEvil:true\r\n"))
    +      c <- client()
    +      status <- c.status(req).handleError(_ => Status.Ok)
    +    } yield assertEquals(status, Status.Ok)
    +  }
    +
       private def checkResponse(rec: Response[IO], expected: Response[IO]): IO[Boolean] = {
         // This isn't a generically safe normalization for all header, but
         // it's close enough for our purposes
    
  • core/shared/src/main/scala/org/http4s/Header.scala+20 3 modified
    @@ -20,7 +20,8 @@ import cats.{Foldable, Hash, Order, Semigroup, Show}
     import cats.data.NonEmptyList
     import cats.syntax.all._
     import org.typelevel.ci.CIString
    -import org.http4s.util.{Renderer, Writer}
    +import org.http4s.internal.CharPredicate
    +import org.http4s.util.{Renderer, StringWriter, Writer}
     import cats.data.Ior
     
     /** Typeclass representing an HTTP header, which all the http4s
    @@ -49,6 +50,17 @@ trait Header[A, T <: Header.Type] {
     object Header {
       final case class Raw(val name: CIString, val value: String) {
         override def toString: String = s"${name}: ${value}"
    +
    +    /** True if [[name]] is a valid field-name per RFC7230.  Where it
    +      * is not, the header may be dropped by the backend.
    +      */
    +    def isNameValid: Boolean =
    +      name.toString.nonEmpty && name.toString.forall(FieldNamePredicate)
    +
    +    def sanitizedValue: String = {
    +      val w = new StringWriter
    +      w.sanitize(_ << value).result
    +    }
       }
     
       object Raw {
    @@ -66,8 +78,10 @@ object Header {
               case c => c
             }
     
    -      def render(writer: Writer, h: Raw): writer.type =
    -        writer << h.name << ':' << ' ' << h.value
    +      def render(writer: Writer, h: Raw): writer.type = {
    +        writer << h.name << ':' << ' '
    +        writer.sanitize(_ << h.value)
    +      }
         }
       }
     
    @@ -235,4 +249,7 @@ object Header {
               }
           }
       }
    +
    +  private val FieldNamePredicate =
    +    CharPredicate("!#$%&'*+-.^_`|~`") ++ CharPredicate.AlphaNum
     }
    
  • core/shared/src/main/scala/org/http4s/internal/package.scala+11 0 modified
    @@ -285,4 +285,15 @@ package object internal {
           tail: Eval[Int]*
       ): Int =
         reduceComparisons_(NonEmptyChain(Eval.now(head), tail: _*))
    +
    +  private[http4s] def appendSanitized(sb: StringBuilder, s: String): Unit = {
    +    val start = sb.length
    +    sb.append(s)
    +    for (i <- start until sb.length) {
    +      val c = sb.charAt(i)
    +      if (c == 0x0.toChar || c == '\r' || c == '\n') {
    +        sb.setCharAt(i, ' ')
    +      }
    +    }
    +  }
     }
    
  • core/shared/src/main/scala/org/http4s/Status.scala+91 69 modified
    @@ -18,6 +18,7 @@ package org.http4s
     
     import cats.{Order, Show}
     import org.http4s.Status.ResponseClass
    +import org.http4s.internal.CharPredicate
     import org.http4s.util.Renderable
     
     /** Representation of the HTTP response code and reason
    @@ -48,7 +49,13 @@ sealed abstract case class Status private (code: Int)(
     
       def withReason(reason: String): Status = Status(code, reason, isEntityAllowed)
     
    -  override def render(writer: org.http4s.util.Writer): writer.type = writer << code << ' ' << reason
    +  /** A sanitized [[reason]] phrase. Blank if reason is invalid per
    +    * RFC7230, otherwise equivalent to reason.
    +    */
    +  def sanitizedReason: String = ""
    +
    +  override def render(writer: org.http4s.util.Writer): writer.type =
    +    writer << code << ' ' << sanitizedReason
     
       /** Helpers for for matching against a [[Response]] */
       def unapply[F[_]](msg: Response[F]): Option[Response[F]] =
    @@ -58,8 +65,23 @@ sealed abstract case class Status private (code: Int)(
     object Status {
       import Registry._
     
    +  private val ReasonPhrasePredicate =
    +    CharPredicate("\t ") ++ CharPredicate(0x21.toChar to 0x7e.toChar) ++ CharPredicate(
    +      0x80.toChar to Char.MaxValue)
    +
       def apply(code: Int, reason: String = "", isEntityAllowed: Boolean = true): Status =
    -    new Status(code)(reason, isEntityAllowed) {}
    +    new Status(code)(reason, isEntityAllowed) {
    +      override lazy val sanitizedReason =
    +        if (reason.forall(ReasonPhrasePredicate))
    +          reason
    +        else
    +          ""
    +    }
    +
    +  private def trust(code: Int, reason: String, isEntityAllowed: Boolean = true): Status =
    +    new Status(code)(reason, isEntityAllowed) {
    +      override val sanitizedReason = reason
    +    }
     
       sealed trait ResponseClass {
         def isSuccess: Boolean
    @@ -85,7 +107,7 @@ object Status {
         withRangeCheck(code) {
           lookup(code) match {
             case right: Right[_, _] => right
    -        case _ => ParseResult.success(Status(code, ""))
    +        case _ => ParseResult.success(trust(code, ""))
           }
         }
     
    @@ -130,74 +152,74 @@ object Status {
       /** Status code list taken from http://www.iana.org/assignments/http-status-codes/http-status-codes.xml
         */
       // scalastyle:off magic.number
    -  val Continue: Status = register(Status(100, "Continue", isEntityAllowed = false))
    +  val Continue: Status = register(trust(100, "Continue", isEntityAllowed = false))
       val SwitchingProtocols: Status = register(
    -    Status(101, "Switching Protocols", isEntityAllowed = false))
    -  val Processing: Status = register(Status(102, "Processing", isEntityAllowed = false))
    -  val EarlyHints: Status = register(Status(103, "Early Hints", isEntityAllowed = false))
    -
    -  val Ok: Status = register(Status(200, "OK"))
    -  val Created: Status = register(Status(201, "Created"))
    -  val Accepted: Status = register(Status(202, "Accepted"))
    -  val NonAuthoritativeInformation: Status = register(Status(203, "Non-Authoritative Information"))
    -  val NoContent: Status = register(Status(204, "No Content", isEntityAllowed = false))
    -  val ResetContent: Status = register(Status(205, "Reset Content", isEntityAllowed = false))
    -  val PartialContent: Status = register(Status(206, "Partial Content"))
    -  val MultiStatus: Status = register(Status(207, "Multi-Status"))
    -  val AlreadyReported: Status = register(Status(208, "Already Reported"))
    -  val IMUsed: Status = register(Status(226, "IM Used"))
    -
    -  val MultipleChoices: Status = register(Status(300, "Multiple Choices"))
    -  val MovedPermanently: Status = register(Status(301, "Moved Permanently"))
    -  val Found: Status = register(Status(302, "Found"))
    -  val SeeOther: Status = register(Status(303, "See Other"))
    -  val NotModified: Status = register(Status(304, "Not Modified", isEntityAllowed = false))
    -  val UseProxy: Status = register(Status(305, "Use Proxy"))
    -  val TemporaryRedirect: Status = register(Status(307, "Temporary Redirect"))
    -  val PermanentRedirect: Status = register(Status(308, "Permanent Redirect"))
    -
    -  val BadRequest: Status = register(Status(400, "Bad Request"))
    -  val Unauthorized: Status = register(Status(401, "Unauthorized"))
    -  val PaymentRequired: Status = register(Status(402, "Payment Required"))
    -  val Forbidden: Status = register(Status(403, "Forbidden"))
    -  val NotFound: Status = register(Status(404, "Not Found"))
    -  val MethodNotAllowed: Status = register(Status(405, "Method Not Allowed"))
    -  val NotAcceptable: Status = register(Status(406, "Not Acceptable"))
    -  val ProxyAuthenticationRequired: Status = register(Status(407, "Proxy Authentication Required"))
    -  val RequestTimeout: Status = register(Status(408, "Request Timeout"))
    -  val Conflict: Status = register(Status(409, "Conflict"))
    -  val Gone: Status = register(Status(410, "Gone"))
    -  val LengthRequired: Status = register(Status(411, "Length Required"))
    -  val PreconditionFailed: Status = register(Status(412, "Precondition Failed"))
    -  val PayloadTooLarge: Status = register(Status(413, "Payload Too Large"))
    -  val UriTooLong: Status = register(Status(414, "URI Too Long"))
    -  val UnsupportedMediaType: Status = register(Status(415, "Unsupported Media Type"))
    -  val RangeNotSatisfiable: Status = register(Status(416, "Range Not Satisfiable"))
    -  val ExpectationFailed: Status = register(Status(417, "Expectation Failed"))
    -  val ImATeapot: Status = register(Status(418, "I'm A Teapot"))
    -  val MisdirectedRequest: Status = register(Status(421, "Misdirected Request"))
    -  val UnprocessableEntity: Status = register(Status(422, "Unprocessable Entity"))
    -  val Locked: Status = register(Status(423, "Locked"))
    -  val FailedDependency: Status = register(Status(424, "Failed Dependency"))
    -  val TooEarly: Status = register(Status(425, "Too Early"))
    -  val UpgradeRequired: Status = register(Status(426, "Upgrade Required"))
    -  val PreconditionRequired: Status = register(Status(428, "Precondition Required"))
    -  val TooManyRequests: Status = register(Status(429, "Too Many Requests"))
    -  val RequestHeaderFieldsTooLarge: Status = register(Status(431, "Request Header Fields Too Large"))
    -  val UnavailableForLegalReasons: Status = register(Status(451, "Unavailable For Legal Reasons"))
    -
    -  val InternalServerError: Status = register(Status(500, "Internal Server Error"))
    -  val NotImplemented: Status = register(Status(501, "Not Implemented"))
    -  val BadGateway: Status = register(Status(502, "Bad Gateway"))
    -  val ServiceUnavailable: Status = register(Status(503, "Service Unavailable"))
    -  val GatewayTimeout: Status = register(Status(504, "Gateway Timeout"))
    -  val HttpVersionNotSupported: Status = register(Status(505, "HTTP Version not supported"))
    -  val VariantAlsoNegotiates: Status = register(Status(506, "Variant Also Negotiates"))
    -  val InsufficientStorage: Status = register(Status(507, "Insufficient Storage"))
    -  val LoopDetected: Status = register(Status(508, "Loop Detected"))
    -  val NotExtended: Status = register(Status(510, "Not Extended"))
    +    trust(101, "Switching Protocols", isEntityAllowed = false))
    +  val Processing: Status = register(trust(102, "Processing", isEntityAllowed = false))
    +  val EarlyHints: Status = register(trust(103, "Early Hints", isEntityAllowed = false))
    +
    +  val Ok: Status = register(trust(200, "OK"))
    +  val Created: Status = register(trust(201, "Created"))
    +  val Accepted: Status = register(trust(202, "Accepted"))
    +  val NonAuthoritativeInformation: Status = register(trust(203, "Non-Authoritative Information"))
    +  val NoContent: Status = register(trust(204, "No Content", isEntityAllowed = false))
    +  val ResetContent: Status = register(trust(205, "Reset Content", isEntityAllowed = false))
    +  val PartialContent: Status = register(trust(206, "Partial Content"))
    +  val MultiStatus: Status = register(trust(207, "Multi-Status"))
    +  val AlreadyReported: Status = register(trust(208, "Already Reported"))
    +  val IMUsed: Status = register(trust(226, "IM Used"))
    +
    +  val MultipleChoices: Status = register(trust(300, "Multiple Choices"))
    +  val MovedPermanently: Status = register(trust(301, "Moved Permanently"))
    +  val Found: Status = register(trust(302, "Found"))
    +  val SeeOther: Status = register(trust(303, "See Other"))
    +  val NotModified: Status = register(trust(304, "Not Modified", isEntityAllowed = false))
    +  val UseProxy: Status = register(trust(305, "Use Proxy"))
    +  val TemporaryRedirect: Status = register(trust(307, "Temporary Redirect"))
    +  val PermanentRedirect: Status = register(trust(308, "Permanent Redirect"))
    +
    +  val BadRequest: Status = register(trust(400, "Bad Request"))
    +  val Unauthorized: Status = register(trust(401, "Unauthorized"))
    +  val PaymentRequired: Status = register(trust(402, "Payment Required"))
    +  val Forbidden: Status = register(trust(403, "Forbidden"))
    +  val NotFound: Status = register(trust(404, "Not Found"))
    +  val MethodNotAllowed: Status = register(trust(405, "Method Not Allowed"))
    +  val NotAcceptable: Status = register(trust(406, "Not Acceptable"))
    +  val ProxyAuthenticationRequired: Status = register(trust(407, "Proxy Authentication Required"))
    +  val RequestTimeout: Status = register(trust(408, "Request Timeout"))
    +  val Conflict: Status = register(trust(409, "Conflict"))
    +  val Gone: Status = register(trust(410, "Gone"))
    +  val LengthRequired: Status = register(trust(411, "Length Required"))
    +  val PreconditionFailed: Status = register(trust(412, "Precondition Failed"))
    +  val PayloadTooLarge: Status = register(trust(413, "Payload Too Large"))
    +  val UriTooLong: Status = register(trust(414, "URI Too Long"))
    +  val UnsupportedMediaType: Status = register(trust(415, "Unsupported Media Type"))
    +  val RangeNotSatisfiable: Status = register(trust(416, "Range Not Satisfiable"))
    +  val ExpectationFailed: Status = register(trust(417, "Expectation Failed"))
    +  val ImATeapot: Status = register(trust(418, "I'm A Teapot"))
    +  val MisdirectedRequest: Status = register(trust(421, "Misdirected Request"))
    +  val UnprocessableEntity: Status = register(trust(422, "Unprocessable Entity"))
    +  val Locked: Status = register(trust(423, "Locked"))
    +  val FailedDependency: Status = register(trust(424, "Failed Dependency"))
    +  val TooEarly: Status = register(trust(425, "Too Early"))
    +  val UpgradeRequired: Status = register(trust(426, "Upgrade Required"))
    +  val PreconditionRequired: Status = register(trust(428, "Precondition Required"))
    +  val TooManyRequests: Status = register(trust(429, "Too Many Requests"))
    +  val RequestHeaderFieldsTooLarge: Status = register(trust(431, "Request Header Fields Too Large"))
    +  val UnavailableForLegalReasons: Status = register(trust(451, "Unavailable For Legal Reasons"))
    +
    +  val InternalServerError: Status = register(trust(500, "Internal Server Error"))
    +  val NotImplemented: Status = register(trust(501, "Not Implemented"))
    +  val BadGateway: Status = register(trust(502, "Bad Gateway"))
    +  val ServiceUnavailable: Status = register(trust(503, "Service Unavailable"))
    +  val GatewayTimeout: Status = register(trust(504, "Gateway Timeout"))
    +  val HttpVersionNotSupported: Status = register(trust(505, "HTTP Version not supported"))
    +  val VariantAlsoNegotiates: Status = register(trust(506, "Variant Also Negotiates"))
    +  val InsufficientStorage: Status = register(trust(507, "Insufficient Storage"))
    +  val LoopDetected: Status = register(trust(508, "Loop Detected"))
    +  val NotExtended: Status = register(trust(510, "Not Extended"))
       val NetworkAuthenticationRequired: Status = register(
    -    Status(511, "Network Authentication Required"))
    +    trust(511, "Network Authentication Required"))
       // scalastyle:on magic.number
     
       implicit val http4sOrderForStatus: Order[Status] = Order.fromOrdering[Status]
    
  • core/shared/src/main/scala/org/http4s/util/Renderable.scala+50 2 modified
    @@ -143,7 +143,7 @@ object Writer {
     }
     
     /** Efficiently accumulate [[Renderable]] representations */
    -trait Writer {
    +trait Writer { self =>
       def append(s: String): this.type
       def append(ci: CIString): this.type = append(ci.toString)
       def append(char: Char): this.type = append(char.toString)
    @@ -242,12 +242,30 @@ trait Writer {
       final def <<(int: Int): this.type = append(int)
       final def <<(long: Long): this.type = append(long)
       final def <<[T: Renderer](r: T): this.type = append(r)
    +
    +  def sanitize(f: Writer => Writer): this.type = {
    +    val w = new Writer {
    +      def append(s: String): this.type = {
    +        s.foreach(append(_))
    +        this
    +      }
    +      override def append(c: Char): this.type = {
    +        if (c == 0x0.toChar || c == '\r' || c == '\n')
    +          self.append(' ')
    +        else
    +          self.append(c)
    +        this
    +      }
    +    }
    +    f(w)
    +    this
    +  }
     }
     
     /** [[Writer]] that will result in a `String`
       * @param size initial buffer size of the underlying `StringBuilder`
       */
    -class StringWriter(size: Int = StringWriter.InitialCapacity) extends Writer {
    +class StringWriter(size: Int = StringWriter.InitialCapacity) extends Writer { self =>
       private val sb = new java.lang.StringBuilder(size)
     
       def append(s: String): this.type = { sb.append(s); this }
    @@ -257,6 +275,31 @@ class StringWriter(size: Int = StringWriter.InitialCapacity) extends Writer {
       override def append(int: Int): this.type = { sb.append(int); this }
       override def append(long: Long): this.type = { sb.append(long); this }
     
    +  override def sanitize(f: Writer => Writer): this.type = {
    +    val w = new Writer {
    +      def append(s: String): this.type = {
    +        val start = sb.length
    +        self.append(s)
    +        for (i <- start until sb.length) {
    +          val c = sb.charAt(i)
    +          if (c == 0x0.toChar || c == '\r' || c == '\n') {
    +            sb.setCharAt(i, ' ')
    +          }
    +        }
    +        this
    +      }
    +      override def append(c: Char): this.type = {
    +        if (c == 0x0.toChar || c == '\r' || c == '\n')
    +          self.append(' ')
    +        else
    +          self.append(c)
    +        this
    +      }
    +    }
    +    f(w)
    +    this
    +  }
    +
       def result: String = sb.toString
     }
     
    @@ -272,4 +315,9 @@ private[http4s] class HeaderLengthCountingWriter extends Writer {
         length = length + s.length
         this
       }
    +
    +  override def sanitize(f: Writer => Writer): this.type = {
    +    f(this)
    +    this
    +  }
     }
    
  • ember-client/shared/src/main/scala/org/http4s/ember/client/EmberClientBuilder.scala+2 3 modified
    @@ -126,7 +126,7 @@ final class EmberClientBuilder[F[_]: Async] private (
           tlsContextOptWithDefault <- Resource.eval(
             tlsContextOpt.fold(Network[F].tlsContext.system.attempt.map(_.toOption))(_.some.pure[F]))
           builder =
    -        KeyPoolBuilder
    +        KeyPool.Builder
               .apply[F, RequestKey, EmberConnection[F]](
                 (requestKey: RequestKey) =>
                   EmberConnection(
    @@ -138,11 +138,10 @@ final class EmberClientBuilder[F[_]: Async] private (
                         sg,
                         additionalSocketOptions
                       )) <* logger.trace(s"Created Connection - RequestKey: ${requestKey}"),
    -            { case connection =>
    +            (connection: EmberConnection[F]) =>
                   logger.trace(
                     s"Shutting Down Connection - RequestKey: ${connection.keySocket.requestKey}") >>
                     connection.cleanup
    -            }
               )
               .withDefaultReuseState(Reusable.DontReuse)
               .withIdleTimeAllowedInPool(idleTimeInPool)
    
  • ember-core/src/main/scala/org/http4s/ember/core/Encoder.scala+70 54 modified
    @@ -16,9 +16,11 @@
     
     package org.http4s.ember.core
     
    +import cats.ApplicativeThrow
     import fs2._
     import org.http4s._
     import org.http4s.headers.{Host, `Content-Length`}
    +import org.http4s.internal.{CharPredicate, appendSanitized}
     import java.nio.charset.StandardCharsets
     
     private[ember] object Encoder {
    @@ -49,12 +51,14 @@ private[ember] object Encoder {
     
           // Apply each header followed by a CRLF
           resp.headers.foreach { h =>
    -        stringBuilder
    -          .append(h.name)
    -          .append(": ")
    -          .append(h.value)
    -          .append(CRLF)
    -        ()
    +        if (h.isNameValid) {
    +          stringBuilder
    +            .append(h.name)
    +            .append(": ")
    +          appendSanitized(stringBuilder, h.value)
    +          stringBuilder.append(CRLF)
    +          ()
    +        }
           }
           if (!chunked && !appliedContentLength && resp.status.isEntityAllowed) {
             stringBuilder.append(chunkedTansferEncodingHeaderRaw).append(CRLF)
    @@ -77,61 +81,73 @@ private[ember] object Encoder {
       private val NoPayloadMethods: Set[Method] =
         Set(Method.GET, Method.DELETE, Method.CONNECT, Method.TRACE)
     
    -  def reqToBytes[F[_]](req: Request[F], writeBufferSize: Int = 32 * 1024): Stream[F, Byte] = {
    -    var chunked = req.isChunked
    -    val initSection = {
    -      var appliedContentLength = false
    -      val stringBuilder = new StringBuilder()
    +  def reqToBytes[F[_]: ApplicativeThrow](
    +      req: Request[F],
    +      writeBufferSize: Int = 32 * 1024): Stream[F, Byte] = {
    +    val uriOriginFormString = req.uri.toOriginForm.renderString
     
    -      // Request-Line   = Method SP Request-URI SP HTTP-Version CRLF
    -      stringBuilder
    -        .append(req.method.renderString)
    -        .append(SPACE)
    -        .append(req.uri.toOriginForm.renderString)
    -        .append(SPACE)
    -        .append(req.httpVersion.renderString)
    -        .append(CRLF)
    +    if (uriOriginFormString.exists(ForbiddenUriCharacters)) {
    +      Stream.raiseError(new IllegalArgumentException(s"Invalid URI: ${uriOriginFormString}"))
    +    } else {
    +      var chunked = req.isChunked
    +      val initSection = {
    +        var appliedContentLength = false
    +        val stringBuilder = new StringBuilder()
     
    -      // Host From Uri Becomes Header if not already present in headers
    -      if (req.headers.get[Host].isEmpty)
    -        req.uri.authority.foreach { auth =>
    -          stringBuilder
    -            .append("Host: ")
    -            .append(auth.renderString)
    -            .append(CRLF)
    -        }
    +        // Request-Line   = Method SP Request-URI SP HTTP-Version CRLF
    +        stringBuilder
    +          .append(req.method.renderString)
    +          .append(SPACE)
    +          .append(uriOriginFormString)
    +          .append(SPACE)
    +          .append(req.httpVersion.renderString)
    +          .append(CRLF)
     
    -      req.headers
    -        .get[`Content-Length`]
    -        .foreach { _ =>
    -          appliedContentLength = true
    +        // Host From Uri Becomes Header if not already present in headers
    +        if (req.headers.get[Host].isEmpty)
    +          req.uri.authority.foreach { auth =>
    +            stringBuilder
    +              .append("Host: ")
    +              .append(auth.renderString)
    +              .append(CRLF)
    +          }
    +
    +        req.headers
    +          .get[`Content-Length`]
    +          .foreach { _ =>
    +            appliedContentLength = true
    +          }
    +
    +        // Apply each header followed by a CRLF
    +        req.headers.foreach { h =>
    +          if (h.isNameValid) {
    +            stringBuilder
    +              .append(h.name)
    +              .append(": ")
    +            appendSanitized(stringBuilder, h.value)
    +            stringBuilder.append(CRLF)
    +            ()
    +          }
             }
     
    -      // Apply each header followed by a CRLF
    -      req.headers.foreach { h =>
    -        stringBuilder
    -          .append(h.name)
    -          .append(": ")
    -          .append(h.value)
    -          .append(CRLF)
    -        ()
    -      }
    +        if (!chunked && !appliedContentLength && !NoPayloadMethods.contains(req.method)) {
    +          stringBuilder.append(chunkedTansferEncodingHeaderRaw).append(CRLF)
    +          chunked = true
    +          ()
    +        }
     
    -      if (!chunked && !appliedContentLength && !NoPayloadMethods.contains(req.method)) {
    -        stringBuilder.append(chunkedTansferEncodingHeaderRaw).append(CRLF)
    -        chunked = true
    -        ()
    +        // Final CRLF terminates headers and signals body to follow.
    +        stringBuilder.append(CRLF)
    +        stringBuilder.toString.getBytes(StandardCharsets.ISO_8859_1)
           }
    -
    -      // Final CRLF terminates headers and signals body to follow.
    -      stringBuilder.append(CRLF)
    -      stringBuilder.toString.getBytes(StandardCharsets.ISO_8859_1)
    +      if (chunked)
    +        Stream.chunk(Chunk.array(initSection)) ++ req.body.through(ChunkedEncoding.encode[F])
    +      else
    +        (Stream.chunk(Chunk.array(initSection)) ++ req.body)
    +          .chunkMin(writeBufferSize)
    +          .flatMap(Stream.chunk)
         }
    -    if (chunked)
    -      Stream.chunk(Chunk.array(initSection)) ++ req.body.through(ChunkedEncoding.encode[F])
    -    else
    -      (Stream.chunk(Chunk.array(initSection)) ++ req.body)
    -        .chunkMin(writeBufferSize)
    -        .flatMap(Stream.chunk)
       }
    +
    +  private val ForbiddenUriCharacters = CharPredicate(0x0.toChar, ' ', '\r', '\n')
     }
    
  • ember-core/src/test/scala/org/http4s/ember/core/RequestSplittingSuite.scala+65 0 added
    @@ -0,0 +1,65 @@
    +/*
    + * Copyright 2019 http4s.org
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package org.http4s
    +package ember.core
    +
    +import cats.effect.{Concurrent, IO}
    +import cats.implicits._
    +import org.typelevel.ci._
    +
    +class RequestSplittingSuite extends Http4sSuite {
    +  def attack[F[_]](req: Request[F])(implicit F: Concurrent[F]): F[Response[F]] =
    +    for {
    +      reqBytes <- Encoder
    +        .reqToBytes(req)
    +        .compile
    +        .to(Array)
    +      _ = println(new String(reqBytes))
    +      app = HttpApp[F] { req =>
    +        (req.headers.get(ci"Evil") match {
    +          case Some(_) => Response[F](Status.InternalServerError)
    +          case None => Response[F](Status.Ok)
    +        }).pure[F]
    +      }
    +      result <- Parser.Request.parser[F](1024)(reqBytes, F.pure(None))
    +      (req0, _) = result
    +      resp <- app(req0)
    +    } yield resp
    +
    +  test("Prevent request splitting attacks on URI path") {
    +    val req = Request[IO](uri = Uri(path =
    +      Uri.Path.Root / Uri.Path.Segment.encoded(" HTTP/1.0\r\nEvil:true\r\nHide-Protocol-Version:")))
    +    attack(req).intercept[IllegalArgumentException]
    +  }
    +
    +  test("Prevent request splitting attacks on URI regname") {
    +    val req = Request[IO](uri = Uri(
    +      authority = Uri.Authority(None, Uri.RegName("example.com\r\nEvil:true\r\n")).some,
    +      path = Uri.Path.Root))
    +    attack(req).map(_.status).assertEquals(Status.Ok)
    +  }
    +
    +  test("Prevent request splitting attacks on field name") {
    +    val req = Request[IO]().putHeaders(Header.Raw(ci"Fine:\r\nEvil:true\r\n", "oops"))
    +    attack(req).map(_.status).assertEquals(Status.Ok)
    +  }
    +
    +  test("Prevent request splitting attacks on field value") {
    +    val req = Request[IO]().putHeaders(Header.Raw(ci"X-Carrier", "\r\nEvil:true\r\n"))
    +    attack(req).map(_.status).assertEquals(Status.Ok)
    +  }
    +}
    
  • ember-core/src/test/scala/org/http4s/ember/core/ResponseSplittingSuite.scala+67 0 added
    @@ -0,0 +1,67 @@
    +/*
    + * Copyright 2019 http4s.org
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package org.http4s
    +package ember.core
    +
    +import cats.effect.{Concurrent, IO}
    +import cats.implicits._
    +import org.http4s.implicits._
    +import org.typelevel.ci._
    +
    +class ResponseSplittingSuite extends Http4sSuite {
    +  def attack[F[_]](app: HttpApp[F], req: Request[F])(implicit F: Concurrent[F]): F[Response[F]] =
    +    for {
    +      resp <- app(req)
    +      respBytes <- Encoder
    +        .respToBytes(resp)
    +        .compile
    +        .to(Array)
    +      result <- Parser.Response.parser[F](1024)(respBytes, F.pure(None))
    +    } yield (result._1)
    +
    +  test("Prevent response splitting attacks on status reason phrase") {
    +    val app = HttpApp[IO] { req =>
    +      Response(Status.NoContent.withReason(req.params("reason"))).pure[IO]
    +    }
    +    val req = Request[IO](uri = uri"/?reason=%0D%0AEvil:true%0D%0A")
    +    attack(app, req).map { resp =>
    +      assertEquals(resp.headers.headers.find(_.name === ci"Evil"), None)
    +    }
    +  }
    +
    +  test("Prevent response splitting attacks on field name") {
    +    val app = HttpApp[IO] { req =>
    +      Response(Status.NoContent).putHeaders(req.params("fieldName") -> "oops").pure[IO]
    +    }
    +    val req = Request[IO](uri = uri"/?fieldName=Fine:%0D%0AEvil:true%0D%0A")
    +    attack(app, req).map { resp =>
    +      assertEquals(resp.headers.headers.find(_.name === ci"Evil"), None)
    +    }
    +  }
    +
    +  test("Prevent response splitting attacks on field value") {
    +    val app = HttpApp[IO] { req =>
    +      Response[IO](Status.NoContent)
    +        .putHeaders("X-Oops" -> req.params("fieldValue"))
    +        .pure[IO]
    +    }
    +    val req = Request[IO](uri = uri"/?fieldValue=%0D%0AEvil:true%0D%0A")
    +    attack(app, req).map { resp =>
    +      assertEquals(resp.headers.headers.find(_.name === ci"Evil"), None)
    +    }
    +  }
    +}
    
  • jetty-client/src/main/scala/org/http4s/jetty/client/JettyClient.scala+8 4 modified
    @@ -44,12 +44,15 @@ object JettyClient {
             Client[F] { req =>
               Resource.suspend(F.async[Resource[F, Response[F]]] { cb =>
                 F.bracket(StreamRequestContentProvider()) { dcp =>
    -              val jReq = toJettyRequest(client, req, dcp)
    -              for {
    +              (for {
    +                jReq <- F.catchNonFatal(toJettyRequest(client, req, dcp))
                     rl <- ResponseListener(cb)
                     _ <- F.delay(jReq.send(rl))
                     _ <- dcp.write(req)
    -              } yield Option.empty[F[Unit]]
    +              } yield Option.empty[F[Unit]]).recover { case e =>
    +                cb(Left(e))
    +                Option.empty[F[Unit]]
    +              }
                 } { dcp =>
                   F.delay(dcp.close())
                 }
    @@ -89,7 +92,8 @@ object JettyClient {
             }
           )
     
    -    for (h <- request.headers.headers) jReq.header(h.name.toString, h.value)
    +    for (h <- request.headers.headers if h.isNameValid)
    +      jReq.header(h.name.toString, h.value)
         jReq.content(dcp)
       }
     }
    
  • laws/src/main/scala/org/http4s/laws/discipline/ArbitraryInstances.scala+24 5 modified
    @@ -33,7 +33,6 @@ import java.nio.charset.{Charset => NioCharset}
     import java.time._
     import java.util.Locale
     import org.http4s.headers._
    -import org.http4s.laws.discipline.ArbitraryInstances.genAlphaToken
     import org.http4s.syntax.literals._
     import org.scalacheck._
     import org.scalacheck.Arbitrary.{arbitrary => getArbitrary}
    @@ -48,6 +47,8 @@ import scala.concurrent.Future
     import scala.util.Try
     
     private[http4s] trait ArbitraryInstances extends Ip4sArbitraryInstances {
    +  import ArbitraryInstances._
    +
       private implicit class ParseResultSyntax[A](self: ParseResult[A]) {
         def yolo: A = self.valueOr(e => sys.error(e.toString))
       }
    @@ -155,12 +156,12 @@ private[http4s] trait ArbitraryInstances extends Ip4sArbitraryInstances {
     
       val genCustomStatus = for {
         code <- genValidStatusCode
    -    reason <- getArbitrary[String]
    -  } yield Status.fromIntAndReason(code, reason).yolo
    +    reason <- genCustomStatusReason
    +  } yield Status.fromInt(code).yolo.withReason(reason)
     
       implicit val http4sTestingArbitraryForStatus: Arbitrary[Status] = Arbitrary(
         frequency(
    -      10 -> genStandardStatus,
    +      4 -> genStandardStatus,
           1 -> genCustomStatus
         ))
       implicit val http4sTestingCogenForStatus: Cogen[Status] =
    @@ -579,7 +580,7 @@ private[http4s] trait ArbitraryInstances extends Ip4sArbitraryInstances {
       implicit val http4sTestingArbitraryForRawHeader: Arbitrary[Header.Raw] =
         Arbitrary {
           for {
    -        token <- genToken
    +        token <- frequency(8 -> genToken, 1 -> asciiStr, 1 -> getArbitrary[String])
             value <- genFieldValue
           } yield Header.Raw(CIString(token), value)
         }
    @@ -1010,4 +1011,22 @@ object ArbitraryInstances extends ArbitraryInstances {
       implicit val http4sTestingCogenForResponsePrelude: Cogen[ResponsePrelude] =
         Cogen[(Headers, HttpVersion, Status)].contramap(value =>
           (value.headers, value.httpVersion, value.status))
    +  val genCustomStatusReason: Gen[String] = {
    +    val word = poisson(5).flatMap(stringOfN(_, alphaChar))
    +    val normal = poisson(3).flatMap(listOfN(_, word)).map(_.mkString(" "))
    +    val exotic = stringOf(
    +      frequency(
    +        1 -> '\t',
    +        1 -> const(' '),
    +        94 -> asciiPrintableChar
    +      ))
    +    val unsanitizedAscii = asciiStr
    +    val unsanitized = getArbitrary[String]
    +    oneOf(
    +      normal,
    +      exotic,
    +      unsanitizedAscii,
    +      unsanitized
    +    )
    +  }
     }
    
  • project/Http4sPlugin.scala+1 1 modified
    @@ -256,7 +256,7 @@ object Http4sPlugin extends AutoPlugin {
         val jawn = "1.2.0"
         val jawnFs2 = "2.1.0"
         val jetty = "9.4.43.v20210629"
    -    val keypool = "0.4.6"
    +    val keypool = "0.4.7"
         val literally = "1.0.2"
         val logback = "1.2.5"
         val log4cats = "2.1.1"
    
  • scalafix/rules/src/main/scala/fix/v0_22.scala+1 1 modified
    @@ -30,6 +30,6 @@ class v0_22 extends SemanticRule("v0_22") {
           "org.http4s.client.okhttp" -> "org.http4s.okhttp.client",
           "org.http4s.client.asynchttpclient" -> "org.http4s.asynchttpclient",
           "org.http4s.client.blaze" -> "org.http4s.blaze.client",
    -      "org.http4s.server.blaze" -> "org.http4s.blaze.server",
    +      "org.http4s.server.blaze" -> "org.http4s.blaze.server"
         )
     }
    
  • scripts/scaffold_server.js+7 1 modified
    @@ -53,8 +53,14 @@ http.createServer(async (req, res) => {
             res.end();
             break;
           default:
    -        res.statusCode = 404;
    +        if (req.url.startsWith('/request-splitting')) {
    +          res.statusCode = req.headers['Evil'] ? 500 : 200;
    +        }
    +        else {
    +          res.statusCode = 404;
    +        }
             res.end();
    +        break;
         }
       } else if (req.method === 'POST') {
         req.on('data', (chunk) => {
    
  • server-testing/src/test/scala/org/http4s/server/ServerRouteTestBattery.scala+9 1 modified
    @@ -18,12 +18,14 @@ package org.http4s.server
     
     import cats.effect.IO
     import cats.effect.kernel.Resource
    +import cats.syntax.all._
     import org.http4s.HttpApp
     import org.http4s.Method
     import org.http4s.Response
     import org.http4s.Status
     import org.http4s.client.ClientRouteTestBattery
     import org.http4s.client.testroutes.GetRoutes
    +import org.typelevel.ci._
     
     abstract class ServerRouteTestBattery(name: String) extends ClientRouteTestBattery(name) {
     
    @@ -38,7 +40,13 @@ object ServerRouteTestBattery {
     
       val App: HttpApp[IO] = HttpApp[IO] { request =>
         val get = Some(request).filter(_.method == Method.GET).flatMap { r =>
    -      GetRoutes.getPaths.get(r.uri.path.toString)
    +      r.uri.path.toString match {
    +        case p if p.startsWith("/request-splitting") =>
    +          if (r.headers.get(ci"Evil").isDefined) IO(Response[IO](Status.InternalServerError)).some
    +          else IO(Response[IO](Status.Ok)).some
    +        case p =>
    +          GetRoutes.getPaths.get(r.uri.path.toString)
    +      }
         }
     
         val post = Some(request).filter(_.method == Method.POST).map { r =>
    
  • tests/shared/src/test/scala/org/http4s/HeaderSuite.scala+22 0 modified
    @@ -17,8 +17,10 @@
     package org.http4s
     
     import cats.kernel.laws.discipline.OrderTests
    +import java.nio.charset.StandardCharsets.ISO_8859_1
     import org.http4s.headers._
     import org.http4s.laws.discipline.ArbitraryInstances._
    +import org.scalacheck.Prop._
     
     class HeaderSuite extends munit.DisciplineSuite {
       test("Headers should Equate same headers") {
    @@ -54,4 +56,24 @@ class HeaderSuite extends munit.DisciplineSuite {
       test("Order instance for Header should be lawful") {
         checkAll("Order[Header]", OrderTests[Header.Raw].order)
       }
    +
    +  test("isNameValid") {
    +    forAll { (h: Header.Raw) =>
    +      val tchar =
    +        Set(0x21.toChar to 0x7e.toChar: _*).diff(Set("\"(),/:;<=>?@[\\]{}": _*)).map(_.toByte)
    +      assertEquals(
    +        h.isNameValid,
    +        h.name.toString.nonEmpty && h.name.toString.getBytes(ISO_8859_1).forall(tchar),
    +        h.name)
    +    }
    +  }
    +
    +  private val ProhibitedFieldValueChars = Set(0x0.toChar, '\r', '\n')
    +
    +  test("sanitizes prohibited header characters") {
    +    forAll { (h: Header.Raw) =>
    +      val s = h.sanitizedValue
    +      assert(!s.exists(ProhibitedFieldValueChars), s)
    +    }
    +  }
     }
    
  • tests/shared/src/test/scala/org/http4s/StatusSpec.scala+15 0 modified
    @@ -18,6 +18,7 @@ package org.http4s
     
     import org.http4s.laws.discipline.arbitrary._
     import cats.kernel.laws.discipline.OrderTests
    +import java.nio.charset.StandardCharsets
     import org.http4s.Status._
     import org.scalacheck.Gen
     import org.scalacheck.Prop.{forAll, propBoolean}
    @@ -114,6 +115,20 @@ class StatusSpec extends Http4sSuite {
         assertEquals(getStatus(NotFound.code, "Not Found").reason, "Not Found")
       }
     
    +  test("all known status have a reason") {
    +    Status.registered.foreach { status =>
    +      assert(status.renderString.drop(4).nonEmpty, status.renderString)
    +    }
    +  }
    +
    +  test("rendering sanitizes statuses") {
    +    forAll { (s: Status) =>
    +      s.renderString
    +        .getBytes(StandardCharsets.ISO_8859_1)
    +        .forall(b => b == ' ' || b == '\t' || (b >= 0x21 && b <= 0x7e) || ((b & 0xff) > 0x80))
    +    }
    +  }
    +
       private def getStatus(code: Int) =
         fromInt(code) match {
           case Right(s) => s
    
  • tests/src/test/scala/org/http4s/internal/StringWriterSuite.scala+53 0 added
    @@ -0,0 +1,53 @@
    +/*
    + * Copyright 2013 http4s.org
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + */
    +
    +package org.http4s
    +package util
    +
    +import org.scalacheck.Arbitrary.arbitrary
    +import org.scalacheck.Gen._
    +import org.scalacheck.Prop._
    +
    +class StringWriterSuite extends Http4sSuite {
    +
    +  test("sanitize works on chars") {
    +    val sw = new StringWriter
    +    sw.sanitize(_ << 'x' << 0x0.toChar << '\r' << '\n' << 'x')
    +    assertEquals("x   x", sw.result)
    +  }
    +
    +  test("sanitize works on strings") {
    +    val sw = new StringWriter
    +    sw.sanitize(_ << "x\u0000\r\nx")
    +    assertEquals("x   x", sw.result)
    +  }
    +
    +  test("sanitizes between appends") {
    +    val forbiddenChars = Set(0x0.toChar, '\r', '\n')
    +    val unsanitaryGen = stringOf(oneOf(oneOf(forbiddenChars), arbitrary[Char]))
    +    forAll(arbitrary[String], unsanitaryGen, arbitrary[String]) {
    +      (s1: String, s2: String, s3: String) =>
    +        s2.exists(forbiddenChars) ==> {
    +          val sw = new StringWriter
    +          (sw << s1).sanitize(_ << s2) << s3
    +          val s = sw.result
    +          assert(s.startsWith(s1))
    +          assert(s.endsWith(s3))
    +          assert(!s.drop(s1.length).dropRight(s3.length).exists(forbiddenChars))
    +        }
    +    }
    +  }
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.