Response Splitting from unsanitized headers in http4s
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.
| Package | Affected versions | Patched versions |
|---|---|---|
org.http4s:http4s-client_2.12Maven | < 0.21.29 | 0.21.29 |
org.http4s:http4s-client_2.12Maven | >= 0.22.0, < 0.22.5 | 0.22.5 |
org.http4s:http4s-client_2.12Maven | >= 0.23.0, < 0.23.4 | 0.23.4 |
org.http4s:http4s-client_2.13Maven | < 0.21.29 | 0.21.29 |
org.http4s:http4s-client_2.13Maven | >= 0.22.0, < 0.22.5 | 0.22.5 |
org.http4s:http4s-client_2.13Maven | >= 0.23.0, < 0.23.4 | 0.23.4 |
org.http4s:http4s-client_3Maven | < 0.21.29 | 0.21.29 |
org.http4s:http4s-client_3Maven | >= 0.22.0, < 0.22.5 | 0.22.5 |
org.http4s:http4s-client_3Maven | >= 0.23.0, < 0.23.4 | 0.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.29 | 0.21.29 |
org.http4s:http4s-server_2.12Maven | >= 0.22.0, < 0.22.5 | 0.22.5 |
org.http4s:http4s-server_2.12Maven | >= 0.23.0, < 0.23.4 | 0.23.4 |
org.http4s:http4s-server_2.13Maven | < 0.21.29 | 0.21.29 |
org.http4s:http4s-server_2.13Maven | >= 0.22.0, < 0.22.5 | 0.22.5 |
org.http4s:http4s-server_2.13Maven | >= 0.23.0, < 0.23.4 | 0.23.4 |
org.http4s:http4s-server_2.13.0-M5Maven | <= 0.21.28 | — |
org.http4s:http4s-server_3Maven | >= 0.22.0, < 0.22.5 | 0.22.5 |
org.http4s:http4s-server_3Maven | >= 0.23.0, < 0.23.4 | 0.23.4 |
Affected products
10- ghsa-coords9 versionspkg:maven/org.http4s/http4s-client_2.12pkg:maven/org.http4s/http4s-client_2.13pkg:maven/org.http4s/http4s-client_3pkg:maven/org.http4s/http4s-server_2.10pkg:maven/org.http4s/http4s-server_2.11pkg:maven/org.http4s/http4s-server_2.12pkg:maven/org.http4s/http4s-server_2.13pkg:maven/org.http4s/http4s-server_2.13.0-M5pkg:maven/org.http4s/http4s-server_3
< 0.21.29+ 8 more
- (no CPE)range: < 0.21.29
- (no CPE)range: < 0.21.29
- (no CPE)range: < 0.21.29
- (no CPE)range: <= 0.21.28
- (no CPE)range: <= 0.21.28
- (no CPE)range: < 0.21.29
- (no CPE)range: < 0.21.29
- (no CPE)range: <= 0.21.28
- (no CPE)range: >= 0.22.0, < 0.22.5
- http4s/http4sv5Range: <= 0.21.28
Patches
1d02007db1da4Merge pull request from GHSA-5vcm-3xc3-w7x3
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- github.com/advisories/GHSA-5vcm-3xc3-w7x3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-41084ghsaADVISORY
- github.com/http4s/http4s/commit/d02007db1da4f8f3df2dbf11f1db9ac7afc3f9d8ghsax_refsource_MISCWEB
- github.com/http4s/http4s/security/advisories/GHSA-5vcm-3xc3-w7x3ghsax_refsource_CONFIRMWEB
- httpwg.org/http-core/draft-ietf-httpbis-semantics-latest.htmlghsax_refsource_MISCWEB
- owasp.org/www-community/attacks/HTTP_Response_Splittingghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.