Local file inclusion vulnerability in http4s
Description
A path traversal vulnerability in http4s's static content services allows unauthorized access to files outside the configured directory.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A path traversal vulnerability in http4s's static content services allows unauthorized access to files outside the configured directory.
Vulnerability
Description
CVE-2020-5280 is a local file inclusion vulnerability in http4s, a Scala HTTP server library. The flaw resides in the URI normalization logic used by FileService, ResourceService, and WebjarService. The server incorrectly handles path segments containing ../ or //, enabling directory traversal attacks [1].
Exploitation
An attacker can craft an HTTP request with a path like /static/../../etc/passwd to navigate outside the intended web root. The vulnerability is exploitable remotely without authentication, as the static content services are typically exposed to all users [1][2]. The fix, shown in the commits, implements stricter validation by rejecting traversal patterns with an exception and resolving paths relative to the real root [3][4].
Impact
Successful exploitation allows an attacker to read arbitrary files from the server's filesystem, potentially leaking sensitive configuration files, credentials, or application source code. Since the affected services are designed to serve static files, the attack uses the legitimate file-serving functionality to bypass access controls.
Mitigation
Users should upgrade to http4s versions 0.18.26, 0.20.20, or 0.21.2, which contain the patch. Version 0.19.0 is deprecated and unsupported. No workarounds are documented; applying the latest patched version is the only recommended mitigation [1].
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-server_2.12Maven | < 0.18.26 | 0.18.26 |
org.http4s:http4s-server_2.12Maven | >= 0.19.0, < 0.20.20 | 0.20.20 |
org.http4s:http4s-server_2.12Maven | >= 0.21.0, < 0.21.2 | 0.21.2 |
Affected products
2- http4s/http4sv5Range: < 0.18.26
Patches
3b87f31b2292dMerge pull request from GHSA-66q9-f7ff-mmx6
16 files changed · +439 −70
build.sbt+8 −0 modified@@ -63,6 +63,14 @@ lazy val server = libraryProject("server") .settings( description := "Base library for building http4s servers" ) + .settings(BuildInfoPlugin.buildInfoScopedSettings(Test)) + .settings(BuildInfoPlugin.buildInfoDefaultSettings) + .settings( + buildInfoKeys := Seq[BuildInfoKey]( + resourceDirectory in Test, + ), + buildInfoPackage := "org.http4s.server.test" + ) .dependsOn(core, testing % "test->test", theDsl % "test->compile") lazy val serverMetrics = libraryProject("server-metrics")
project/build.properties+1 −1 modified@@ -1 +1 @@ -sbt.version=1.2.3 +sbt.version=1.3.8
project/plugins.sbt+1 −1 modified@@ -4,7 +4,7 @@ addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "4.0.2") addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.7.2") addSbtPlugin("com.lucidchart" % "sbt-scalafmt-coursier" % "1.15") addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") -addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.1.18") +addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0") addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.3") addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.3.15") addSbtPlugin("io.gatling" % "gatling-sbt" % "2.2.2")
server/src/main/scala/org/http4s/server/staticcontent/FileService.scala+49 −15 modified@@ -4,12 +4,22 @@ package staticcontent import cats.data._ import cats.effect._ +import cats.implicits._ import java.io.File +import java.nio.file.{LinkOption, Paths} import org.http4s.headers.Range.SubRange import org.http4s.headers._ +import org.http4s.server.middleware.TranslateUri +import org.http4s.util.UrlCodingUtils.urlDecode +import org.log4s.getLogger import scala.concurrent.ExecutionContext +import scala.util.control.NoStackTrace +import scala.util.{Failure, Success, Try} +import java.nio.file.NoSuchFileException object FileService { + private[this] val logger = getLogger + type PathCollector[F[_]] = (File, Config[F], Request[F]) => OptionT[F, Response[F]] /** [[org.http4s.server.staticcontent.FileService]] configuration @@ -42,14 +52,46 @@ object FileService { } /** Make a new [[org.http4s.HttpService]] that serves static files. */ - private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Effect[F]): HttpService[F] = - Kleisli { - case request if request.pathInfo.startsWith(config.pathPrefix) => - getFile(s"${config.systemPath}/${getSubPath(request.pathInfo, config.pathPrefix)}") - .flatMap(f => config.pathCollector(f, config, request)) - .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) - case _ => OptionT.none + private[staticcontent] def apply[F[_]](config: Config[F])( + implicit F: Effect[F]): HttpService[F] = { + object BadTraversal extends Exception with NoStackTrace + Try(Paths.get(config.systemPath).toRealPath()) match { + case Success(rootPath) => + TranslateUri(config.pathPrefix)(Kleisli { + case request => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(rootPath) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(urlDecode(segment, plusIsSpace = true)) + } + }) + .semiflatMap(path => F.delay(path.toRealPath(LinkOption.NOFOLLOW_LINKS))) + .collect { case path if path.startsWith(rootPath) => path.toFile } + .flatMap(f => config.pathCollector(f, config, request)) + .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) + .recoverWith { + case _: NoSuchFileException => OptionT.none + case BadTraversal => OptionT.some(Response(Status.BadRequest)) + } + case _ => OptionT.none + } + }) + + case Failure(_: NoSuchFileException) => + logger.error( + s"Could not find root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will return none.") + Kleisli(_ => OptionT.none) + + case Failure(e) => + logger.error(e)( + s"Could not resolve root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will fail with a 500.") + Kleisli(_ => OptionT.pure(Response(Status.InternalServerError))) } + } private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])( implicit F: Sync[F]): OptionT[F, Response[F]] = @@ -95,12 +137,4 @@ object FileService { case _ => OptionT.none } - - // Attempts to sanitize the file location and retrieve the file. Returns None if the file doesn't exist. - private def getFile[F[_]](unsafePath: String)(implicit F: Sync[F]): OptionT[F, File] = - OptionT(F.delay { - val f = new File(PathNormalizer.removeDotSegments(unsafePath)) - if (f.exists()) Some(f) - else None - }) }
server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala+51 −12 modified@@ -4,9 +4,17 @@ package staticcontent import cats.data.{Kleisli, OptionT} import cats.effect._ +import cats.implicits._ +import java.nio.file.Paths +import org.http4s.server.middleware.TranslateUri +import org.http4s.util.UrlCodingUtils.urlDecode +import org.log4s.getLogger import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success, Try} +import scala.util.control.NoStackTrace object ResourceService { + private[this] val logger = getLogger /** [[org.http4s.server.staticcontent.ResourceService]] configuration * @@ -26,17 +34,48 @@ object ResourceService { preferGzipped: Boolean = false) /** Make a new [[org.http4s.HttpService]] that serves static files. */ - private[staticcontent] def apply[F[_]: Effect](config: Config[F]): HttpService[F] = - Kleisli { - case request if request.pathInfo.startsWith(config.pathPrefix) => - StaticFile - .fromResource( - PathNormalizer.removeDotSegments( - s"${config.basePath}/${getSubPath(request.pathInfo, config.pathPrefix)}"), - Some(request), - preferGzipped = config.preferGzipped - ) - .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) - case _ => OptionT.none + private[staticcontent] def apply[F[_]](config: Config[F])( + implicit F: Effect[F]): HttpService[F] = { + val basePath = if (config.basePath.isEmpty) "/" else config.basePath + object BadTraversal extends Exception with NoStackTrace + + Try(Paths.get(basePath)) match { + case Success(rootPath) => + TranslateUri(config.pathPrefix)(Kleisli { + case request => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(rootPath) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(urlDecode(segment, plusIsSpace = true)) + } + }) + .collect { + case path if path.startsWith(rootPath) => path + } + .flatMap { path => + StaticFile.fromResource( + path.toString, + Some(request), + preferGzipped = config.preferGzipped + ) + } + .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) + .recoverWith { + case BadTraversal => OptionT.some(Response(Status.BadRequest)) + } + case _ => + OptionT.none + } + }) + + case Failure(e) => + logger.error(e)( + s"Could not get root path from ResourceService config: basePath = ${config.basePath}, pathPrefix = ${config.pathPrefix}. All requests will fail.") + Kleisli(_ => OptionT.pure(Response(Status.InternalServerError))) } + } }
server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala+41 −18 modified@@ -4,6 +4,10 @@ package staticcontent import cats.data.{Kleisli, OptionT} import cats.effect.Effect +import cats.implicits._ +import java.nio.file.{Path, Paths} +import org.http4s.util.UrlCodingUtils.urlDecode +import scala.util.control.NoStackTrace /** * Constructs new services to serve assets from Webjars @@ -51,16 +55,32 @@ object WebjarService { * @param config The configuration for this service * @return The HttpService */ - def apply[F[_]: Effect](config: Config[F]): HttpService[F] = Kleisli { - // Intercepts the routes that match webjar asset names - case request if request.method == Method.GET => - OptionT - .pure[F](request.pathInfo) - .map(PathNormalizer.removeDotSegments) - .subflatMap(toWebjarAsset) - .filter(config.filter) - .flatMap(serveWebjarAsset(config, request)(_)) - case _ => OptionT.none + def apply[F[_]](config: Config[F])(implicit F: Effect[F]): HttpService[F] = { + object BadTraversal extends Exception with NoStackTrace + val Root = Paths.get("") + Kleisli { + // Intercepts the routes that match webjar asset names + case request if request.method == Method.GET => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(Root) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(urlDecode(segment, plusIsSpace = true)) + } + }) + .subflatMap(toWebjarAsset) + .filter(config.filter) + .flatMap(serveWebjarAsset(config, request)(_)) + .recover { + case BadTraversal => Response(Status.BadRequest) + } + case _ => OptionT.none + } + case _ => OptionT.none + } } /** @@ -69,14 +89,17 @@ object WebjarService { * @param subPath The request path without the prefix * @return The WebjarAsset, or None if it couldn't be mapped */ - private def toWebjarAsset(subPath: String): Option[WebjarAsset] = - Option(subPath) - .map(_.split("/", 4)) - .collect { - case Array("", library, version, asset) - if library.nonEmpty && version.nonEmpty && asset.nonEmpty => - WebjarAsset(library, version, asset) - } + private def toWebjarAsset(p: Path): Option[WebjarAsset] = { + val count = p.getNameCount + if (count > 2) { + val library = p.getName(0).toString + val version = p.getName(1).toString + val asset = p.subpath(2, count) + Some(WebjarAsset(library, version, asset.toString)) + } else { + None + } + } /** * Returns an asset that matched the request if it's found in the webjar path
server/src/test/resources/Dir/partial-prefix.txt+1 −0 added@@ -0,0 +1 @@ +I am useful to test leaks from prefix paths.
server/src/test/resources/META-INF/resources/webjars/deep purple/machine head/space truckin'.txt+5 −0 added@@ -0,0 +1,5 @@ +Come on +Come on +Come on +Let's go +Space truckin'
server/src/test/resources/space truckin'.txt+5 −0 added@@ -0,0 +1,5 @@ +Come on +Come on +Come on +Let's go +Space truckin'
server/src/test/resources/symlink+1 −0 added@@ -0,0 +1 @@ +../scala \ No newline at end of file
server/src/test/resources/test/keep.txt+1 −0 added@@ -0,0 +1 @@ +.
server/src/test/scala/org/http4s/server/staticcontent/FileServiceSpec.scala+124 −12 modified@@ -4,66 +4,164 @@ package staticcontent import cats.effect._ import fs2._ -import java.io.File +import java.nio.file._ import org.http4s.server.middleware.TranslateUri class FileServiceSpec extends Http4sSpec with StaticContentShared { - val s = fileService(FileService.Config[IO](new File(getClass.getResource("/").toURI).getPath)) + val defaultSystemPath = test.BuildInfo.test_resourceDirectory.getAbsolutePath + val s = fileService(FileService.Config[IO](defaultSystemPath)) "FileService" should { "Respect UriTranslation" in { val s2 = TranslateUri("/foo")(s) { - val req = Request[IO](uri = uri("foo/testresource.txt")) + val req = Request[IO](uri = uri("/foo/testresource.txt")) s2.orNotFound(req) must returnBody(testResource) s2.orNotFound(req) must returnStatus(Status.Ok) } { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) s2.orNotFound(req) must returnStatus(Status.NotFound) } } "Return a 200 Ok file" in { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) s.orNotFound(req) must returnBody(testResource) s.orNotFound(req) must returnStatus(Status.Ok) } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/space+truckin%27.txt")) + s.orNotFound(req) must returnStatus(Status.Ok) + } + + "Respect the path prefix" in { + val relativePath = "testresource.txt" + val s0 = fileService( + FileService.Config[IO]( + systemPath = defaultSystemPath, + pathPrefix = "/path-prefix" + )) + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + val uri = Uri.unsafeFromString("/path-prefix/" + relativePath) + val req = Request[IO](uri = uri) + s0.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../testresource.txt" + val systemPath = Paths.get(defaultSystemPath).resolve("testDir") + val file = systemPath.resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = systemPath.toString + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 on path traversal, even if it's inside the context" in { + val relativePath = "testDir/../testresource.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial system path prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = Paths.get(defaultSystemPath).resolve("test").toString + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial path-prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/prefix" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = defaultSystemPath, + pathPrefix = "/prefix" + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 400 if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultSystemPath).resolve("testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "return files included via symlink" in { + val relativePath = "symlink/org/http4s/server/staticcontent/FileServiceSpec.scala" + val path = Paths.get(defaultSystemPath).resolve(relativePath) + val file = path.toFile + Files.isSymbolicLink(Paths.get(defaultSystemPath).resolve("symlink")) must beTrue + file.exists() must beTrue + val bytes = Chunk.bytes(Files.readAllBytes(path)) + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.Ok) + s.orNotFound(req) must returnBody(bytes) + } + "Return index.html if request points to a directory" in { - val req = Request[IO](uri = uri("testDir/")) + val req = Request[IO](uri = uri("/testDir/")) val rb = runReq(req) rb._2.as[String] must returnValue("<html>Hello!</html>") rb._2.status must_== Status.Ok } "Not find missing file" in { - val req = Request[IO](uri = uri("testresource.txtt")) + val req = Request[IO](uri = uri("/missing.txt")) s.orNotFound(req) must returnStatus(Status.NotFound) } "Return a 206 PartialContent file" in { val range = headers.Range(4) - val req = Request[IO](uri = uri("testresource.txt")).replaceAllHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).replaceAllHeaders(range) s.orNotFound(req) must returnStatus(Status.PartialContent) s.orNotFound(req) must returnBody(Chunk.bytes(testResource.toArray.splitAt(4)._2)) } "Return a 206 PartialContent file" in { val range = headers.Range(-4) - val req = Request[IO](uri = uri("testresource.txt")).replaceAllHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).replaceAllHeaders(range) s.orNotFound(req) must returnStatus(Status.PartialContent) s.orNotFound(req) must returnBody( Chunk.bytes(testResource.toArray.splitAt(testResource.size - 4)._2)) } "Return a 206 PartialContent file" in { val range = headers.Range(2, 4) - val req = Request[IO](uri = uri("testresource.txt")).replaceAllHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).replaceAllHeaders(range) s.orNotFound(req) must returnStatus(Status.PartialContent) s.orNotFound(req) must returnBody(Chunk.bytes(testResource.toArray.slice(2, 4 + 1))) // the end number is inclusive in the Range header } @@ -76,12 +174,26 @@ class FileServiceSpec extends Http4sSpec with StaticContentShared { headers.Range(200, 201), headers.Range(-200) ) - val reqs = ranges.map(r => Request[IO](uri = uri("testresource.txt")).replaceAllHeaders(r)) + val reqs = ranges.map(r => Request[IO](uri = uri("/testresource.txt")).replaceAllHeaders(r)) forall(reqs) { req => s.orNotFound(req) must returnStatus(Status.Ok) s.orNotFound(req) must returnBody(testResource) } } - } + "doesn't crash on /" in { + s.orNotFound(Request[IO](uri = uri("/"))) must returnStatus(Status.NotFound) + } + + "handle a relative system path" in { + val s = fileService(FileService.Config[IO](".")) + Paths.get(".").resolve("build.sbt").toFile.exists() must beTrue + s.orNotFound(Request[IO](uri = uri("/build.sbt"))) must returnStatus(Status.Ok) + } + + "404 if system path is not found" in { + val s = fileService(FileService.Config[IO]("./does-not-exist")) + s.orNotFound(Request[IO](uri = uri("/build.sbt"))) must returnStatus(Status.NotFound) + } + } }
server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSpec.scala+100 −7 modified@@ -3,12 +3,14 @@ package server package staticcontent import cats.effect._ +import java.nio.file.Paths import org.http4s.headers.{`Accept-Encoding`, `If-Modified-Since`} import org.http4s.server.middleware.TranslateUri class ResourceServiceSpec extends Http4sSpec with StaticContentShared { val config = ResourceService.Config[IO]("", executionContext = Http4sSpec.TestExecutionContext) + val defaultBase = getClass.getResource("/").getPath.toString val s = resourceService(config) "ResourceService" should { @@ -17,28 +19,115 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { val s2 = TranslateUri("/foo")(s) { - val req = Request[IO](uri = uri("foo/testresource.txt")) + val req = Request[IO](uri = uri("/foo/testresource.txt")) s2.orNotFound(req) must returnBody(testResource) s2.orNotFound(req) must returnStatus(Status.Ok) } { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) s2.orNotFound(req) must returnStatus(Status.NotFound) } } "Serve available content" in { - val req = Request[IO](uri = Uri.fromString("testresource.txt").yolo) + val req = Request[IO](uri = Uri.fromString("/testresource.txt").yolo) val rb = s.orNotFound(req) rb must returnBody(testResource) rb must returnStatus(Status.Ok) } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/space+truckin%27.txt")) + s.orNotFound(req) must returnStatus(Status.Ok) + } + + "Respect the path prefix" in { + val relativePath = "testresource.txt" + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + pathPrefix = "/path-prefix" + )) + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + val uri = Uri.unsafeFromString("/path-prefix/" + relativePath) + val req = Request[IO](uri = uri) + s0.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../testresource.txt" + val basePath = Paths.get(defaultBase).resolve("testDir") + val file = basePath.resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "/testDir" + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 on path traversal, even if it's inside the context" in { + val relativePath = "testDir/../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial base path prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "" + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial path-prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + pathPrefix = "/test" + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 400 Not Found if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultBase).resolve("testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "/testDir" + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + "Try to serve pre-gzipped content if asked to" in { val req = Request[IO]( - uri = Uri.fromString("testresource.txt").yolo, + uri = Uri.fromString("/testresource.txt").yolo, headers = Headers(`Accept-Encoding`(ContentCoding.gzip)) ) val rb = resourceService(config.copy(preferGzipped = true)).orNotFound(req) @@ -51,7 +140,7 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { "Fallback to un-gzipped file if pre-gzipped version doesn't exist" in { val req = Request[IO]( - uri = Uri.fromString("testresource2.txt").yolo, + uri = Uri.fromString("/testresource2.txt").yolo, headers = Headers(`Accept-Encoding`(ContentCoding.gzip)) ) val rb = resourceService(config.copy(preferGzipped = true)).orNotFound(req) @@ -63,15 +152,19 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { } "Generate non on missing content" in { - val req = Request[IO](uri = Uri.fromString("testresource.txtt").yolo) + val req = Request[IO](uri = Uri.fromString("/testresource.txtt").yolo) s.orNotFound(req) must returnStatus(Status.NotFound) } "Not send unmodified files" in { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) .putHeaders(`If-Modified-Since`(HttpDate.MaxValue)) runReq(req)._2.status must_== Status.NotModified } + + "doesn't crash on /" in { + s.orNotFound(Request[IO](uri = uri("/"))) must returnStatus(Status.NotFound) + } } }
server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSpec.scala+41 −3 modified@@ -3,12 +3,15 @@ package server package staticcontent import cats.effect._ +import java.nio.file.Paths import org.http4s.Method.{GET, POST} import org.http4s.server.staticcontent.WebjarService.Config object WebjarServiceSpec extends Http4sSpec with StaticContentShared { def s: HttpService[IO] = webjarService(Config[IO]()) + val defaultBase = + test.BuildInfo.test_resourceDirectory.toPath.resolve("META-INF/resources/webjars").toString "The WebjarService" should { @@ -28,6 +31,41 @@ object WebjarServiceSpec extends Http4sSpec with StaticContentShared { rb._2.status must_== Status.Ok } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/deep+purple/machine+head/space+truckin%27.txt")) + s.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 on a relative link even if it's inside the context" in { + val relativePath = "test-lib/1.0.0/sub/../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../../../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultBase).resolve("test-lib/1.0.0/testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + s.orNotFound(req) must returnStatus(Status.BadRequest) + } + "Not find missing file" in { val req = Request[IO](uri = uri("/test-lib/1.0.0/doesnotexist.txt")) s.apply(req).value must returnValue(None) @@ -38,12 +76,12 @@ object WebjarServiceSpec extends Http4sSpec with StaticContentShared { s.apply(req).value must returnValue(None) } - "Not find missing version" in { + "Return bad request on missing version" in { val req = Request[IO](uri = uri("/test-lib//doesnotexist.txt")) - s.apply(req).value must returnValue(None) + s.orNotFound(req) must returnStatus(Status.BadRequest) } - "Not find missing asset" in { + "Not find blank asset" in { val req = Request[IO](uri = uri("/test-lib/1.0.0/")) s.apply(req).value must returnValue(None) }
version.sbt+1 −1 modified@@ -1 +1 @@ -version in ThisBuild := "0.18.26-SNAPSHOT" +version in ThisBuild := "0.18.27-SNAPSHOT"
website/src/hugo/content/changelog.md+9 −0 modified@@ -8,6 +8,15 @@ Maintenance branches are merged before each new release. This change log is ordered chronologically, so each release contains all changes described below it. +# v0.18.26 + +This release is fully backward compatible with 0.18.25. + +## Security fixes +* [GHSA-66q9-f7ff-mmx6](https://github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6): Fixes a local file inclusion vulnerability in `FileService`, `ResourceService`, and `WebjarService`. + * Request paths with `.`, `..`, or empty segments will now return a 400 in all three services. Combinations of these could formerly be used to escape the configured roots and expose arbitrary local resources. + * Request path segments are now percent-decoded to support resources with reserved characters in the name. + # v0.18.25 (2020-01-21) ## Bug fixes
250afddbb2e6Merge pull request from GHSA-66q9-f7ff-mmx6
16 files changed · +632 −69
build.sbt+8 −0 modified@@ -116,6 +116,14 @@ lazy val server = libraryProject("server") .settings( description := "Base library for building http4s servers" ) + .settings(BuildInfoPlugin.buildInfoScopedSettings(Test)) + .settings(BuildInfoPlugin.buildInfoDefaultSettings) + .settings( + buildInfoKeys := Seq[BuildInfoKey]( + resourceDirectory in Test, + ), + buildInfoPackage := "org.http4s.server.test" + ) .dependsOn(core, testing % "test->test", theDsl % "test->compile") lazy val prometheusMetrics = libraryProject("prometheus-metrics")
core/src/main/scala/org/http4s/metrics/MetricsOps.scala+81 −1 modified@@ -1,6 +1,8 @@ package org.http4s.metrics -import org.http4s.{Method, Status} +import cats.Foldable +import cats.implicits._ +import org.http4s.{Method, Request, Status, Uri} /** * Describes an algebra capable of writing metrics to a metrics registry @@ -57,6 +59,84 @@ trait MetricsOps[F[_]] { classifier: Option[String]): F[Unit] } +object MetricsOps { + + /** + * Given an exclude function, return a 'classifier' function, i.e. for application in + * org.http4s.server/client.middleware.Metrics#apply. + * + * Let's say you want a classifier that excludes integers since your paths consist of: + * * GET /users/{integer} = GET_users_* + * * POST /users = POST_users + * * PUT /users/{integer} = PUT_users_* + * * DELETE /users/{integer} = DELETE_users_* + * + * In such a case, we could use: + * + * classifierFMethodWithOptionallyExcludedPath( + * exclude = { str: String => scala.util.Try(str.toInt).isSuccess }, + * excludedValue = "*", + * intercalateValue = "_" + * ) + * + * + * Chris Davenport notes the following on performance considerations of exclude's function value: + * + * > It's worth noting that this runs on every segment of a path. So note that if an intermediate Throwables with + * > Stack traces is known and discarded, there may be a performance penalty, such as the above example with Try(str.toInt). + * > I benchmarked some approaches and regex matches should generally be preferred over Throwable's + * > in this position. + * + * @param exclude For a given String, namely a path value, determine whether the value gets excluded. + * @param excludedValue Indicates the String value to be supplied for an excluded path's field. + * @param pathSeparator Value to use for separating the metrics fields' values + * @return Request[F] => Option[String] + */ + def classifierFMethodWithOptionallyExcludedPath[F[_]]( + exclude: String => Boolean, + excludedValue: String = "*", + pathSeparator: String = "_" + ): Request[F] => Option[String] = { request: Request[F] => + val initial: String = request.method.name + + val pathList: List[String] = + requestToPathList(request) + + val minusExcluded: List[String] = pathList.map { value: String => + if (exclude(value)) excludedValue else value + } + + val result: String = + minusExcluded match { + case Nil => initial + case nonEmpty @ _ :: _ => + initial + pathSeparator + Foldable[List] + .intercalate(nonEmpty, pathSeparator) + } + + Some(result) + } + + // The following was copied from + // https://github.com/http4s/http4s/blob/v0.20.17/dsl/src/main/scala/org/http4s/dsl/impl/Path.scala#L56-L64, + // and then modified. + private def requestToPathList[F[_]](request: Request[F]): List[String] = { + val str: String = request.pathInfo + + if (str == "" || str == "/") + Nil + else { + val segments = str.split("/", -1) + // .head is safe because split always returns non-empty array + val segments0 = if (segments.head == "") segments.drop(1) else segments + val reversed: List[String] = + segments0.foldLeft[List[String]](Nil)((path, seg) => Uri.decode(seg) :: path) + reversed.reverse + } + } + +} + /** Describes the type of abnormal termination*/ sealed trait TerminationType
project/plugins.sbt+1 −1 modified@@ -11,7 +11,7 @@ addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5. addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.2.12") addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.9.6") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.0") -addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.6.4") +addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0") addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3") addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.4.0") addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.5.0")
server/src/main/scala/org/http4s/server/staticcontent/FileService.scala+46 −15 modified@@ -6,10 +6,18 @@ import cats.data.{Kleisli, NonEmptyList, OptionT} import cats.effect.{Blocker, ContextShift, Sync} import cats.implicits._ import java.io.File +import java.nio.file.NoSuchFileException +import java.nio.file.{LinkOption, Paths} import org.http4s.headers.Range.SubRange import org.http4s.headers._ +import org.http4s.server.middleware.TranslateUri +import org.log4s.getLogger +import scala.util.control.NoStackTrace +import scala.util.{Failure, Success, Try} object FileService { + private[this] val logger = getLogger + type PathCollector[F[_]] = (File, Config[F], Request[F]) => OptionT[F, Response[F]] /** [[org.http4s.server.staticcontent.FileService]] configuration @@ -42,14 +50,45 @@ object FileService { } /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ - private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Sync[F]): HttpRoutes[F] = - Kleisli { - case request if request.pathInfo.startsWith(config.pathPrefix) => - OptionT(getFile(s"${config.systemPath}/${getSubPath(request.pathInfo, config.pathPrefix)}")) - .flatMap(f => config.pathCollector(f, config, request)) - .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) - case _ => OptionT.none + private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Sync[F]): HttpRoutes[F] = { + object BadTraversal extends Exception with NoStackTrace + Try(Paths.get(config.systemPath).toRealPath()) match { + case Success(rootPath) => + TranslateUri(config.pathPrefix)(Kleisli { + case request => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(rootPath) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(Uri.decode(segment, plusIsSpace = true)) + } + }) + .semiflatMap(path => F.delay(path.toRealPath(LinkOption.NOFOLLOW_LINKS))) + .collect { case path if path.startsWith(rootPath) => path.toFile } + .flatMap(f => config.pathCollector(f, config, request)) + .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) + .recoverWith { + case _: NoSuchFileException => OptionT.none + case BadTraversal => OptionT.some(Response(Status.BadRequest)) + } + case _ => OptionT.none + } + }) + + case Failure(_: NoSuchFileException) => + logger.error( + s"Could not find root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will return none.") + Kleisli(_ => OptionT.none) + + case Failure(e) => + logger.error(e)( + s"Could not resolve root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will fail with a 500.") + Kleisli(_ => OptionT.pure(Response(Status.InternalServerError))) } + } private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])( implicit F: Sync[F], @@ -115,12 +154,4 @@ object FileService { } case _ => F.pure(None) } - - // Attempts to sanitize the file location and retrieve the file. Returns None if the file doesn't exist. - private def getFile[F[_]](unsafePath: String)(implicit F: Sync[F]): F[Option[File]] = - F.delay { - val f = new File(Uri.removeDotSegments(unsafePath)) - if (f.exists()) Some(f) - else None - } }
server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala+51 −13 modified@@ -4,8 +4,15 @@ package staticcontent import cats.data.{Kleisli, OptionT} import cats.effect.{Blocker, ContextShift, Sync} +import cats.implicits._ +import java.nio.file.Paths +import org.http4s.server.middleware.TranslateUri +import org.log4s.getLogger +import scala.util.control.NoStackTrace +import scala.util.{Failure, Success, Try} object ResourceService { + private[this] val logger = getLogger /** [[org.http4s.server.staticcontent.ResourceService]] configuration * @@ -25,18 +32,49 @@ object ResourceService { preferGzipped: Boolean = false) /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ - private[staticcontent] def apply[F[_]: Sync: ContextShift](config: Config[F]): HttpRoutes[F] = - Kleisli { - case request if request.pathInfo.startsWith(config.pathPrefix) => - StaticFile - .fromResource( - Uri.removeDotSegments( - s"${config.basePath}/${getSubPath(request.pathInfo, config.pathPrefix)}"), - config.blocker, - Some(request), - preferGzipped = config.preferGzipped - ) - .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) - case _ => OptionT.none + private[staticcontent] def apply[F[_]]( + config: Config[F])(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = { + val basePath = if (config.basePath.isEmpty) "/" else config.basePath + object BadTraversal extends Exception with NoStackTrace + + Try(Paths.get(basePath)) match { + case Success(rootPath) => + TranslateUri(config.pathPrefix)(Kleisli { + case request => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(rootPath) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(Uri.decode(segment, plusIsSpace = true)) + } + }) + .collect { + case path if path.startsWith(rootPath) => path + } + .flatMap { path => + StaticFile.fromResource( + path.toString, + config.blocker, + Some(request), + preferGzipped = config.preferGzipped + ) + } + .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) + .recoverWith { + case BadTraversal => OptionT.some(Response(Status.BadRequest)) + } + case _ => + OptionT.none + } + }) + + case Failure(e) => + logger.error(e)( + s"Could not get root path from ResourceService config: basePath = ${config.basePath}, pathPrefix = ${config.pathPrefix}. All requests will fail.") + Kleisli(_ => OptionT.pure(Response(Status.InternalServerError))) } + } }
server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala+40 −18 modified@@ -4,6 +4,9 @@ package staticcontent import cats.data.{Kleisli, OptionT} import cats.effect.{Blocker, ContextShift, Sync} +import cats.implicits._ +import java.nio.file.{Path, Paths} +import scala.util.control.NoStackTrace /** * Constructs new services to serve assets from Webjars @@ -52,16 +55,32 @@ object WebjarService { * @param config The configuration for this service * @return The HttpRoutes */ - def apply[F[_]: Sync: ContextShift](config: Config[F]): HttpRoutes[F] = Kleisli { - // Intercepts the routes that match webjar asset names - case request if request.method == Method.GET => - val uri = Uri.removeDotSegments(request.pathInfo) - toWebjarAsset(uri) match { - case Some(asset) if config.filter(asset) => - serveWebjarAsset(config, request)(asset) - case _ => OptionT.none - } - case _ => OptionT.none + def apply[F[_]](config: Config[F])(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = { + object BadTraversal extends Exception with NoStackTrace + val Root = Paths.get("") + Kleisli { + // Intercepts the routes that match webjar asset names + case request if request.method == Method.GET => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(Root) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(Uri.decode(segment, plusIsSpace = true)) + } + }) + .subflatMap(toWebjarAsset) + .filter(config.filter) + .flatMap(serveWebjarAsset(config, request)(_)) + .recover { + case BadTraversal => Response(Status.BadRequest) + } + case _ => OptionT.none + } + case _ => OptionT.none + } } /** @@ -70,14 +89,17 @@ object WebjarService { * @param subPath The request path without the prefix * @return The WebjarAsset, or None if it couldn't be mapped */ - private def toWebjarAsset(subPath: String): Option[WebjarAsset] = - Option(subPath) - .map(_.split("/", 4)) - .collect { - case Array("", library, version, asset) - if library.nonEmpty && version.nonEmpty && asset.nonEmpty => - WebjarAsset(library, version, asset) - } + private def toWebjarAsset(p: Path): Option[WebjarAsset] = { + val count = p.getNameCount + if (count > 2) { + val library = p.getName(0).toString + val version = p.getName(1).toString + val asset = p.subpath(2, count) + Some(WebjarAsset(library, version, asset.toString)) + } else { + None + } + } /** * Returns an asset that matched the request if it's found in the webjar path
server/src/test/resources/Dir/partial-prefix.txt+1 −0 added@@ -0,0 +1 @@ +I am useful to test leaks from prefix paths.
server/src/test/resources/META-INF/resources/webjars/deep purple/machine head/space truckin'.txt+5 −0 added@@ -0,0 +1,5 @@ +Come on +Come on +Come on +Let's go +Space truckin'
server/src/test/resources/space truckin'.txt+5 −0 added@@ -0,0 +1,5 @@ +Come on +Come on +Come on +Let's go +Space truckin'
server/src/test/resources/symlink+1 −0 added@@ -0,0 +1 @@ +../scala \ No newline at end of file
server/src/test/resources/test/keep.txt+1 −0 added@@ -0,0 +1 @@ +.
server/src/test/scala/org/http4s/server/staticcontent/FileServiceSpec.scala+127 −10 modified@@ -5,12 +5,14 @@ package staticcontent import cats.effect.IO import fs2._ import java.io.File +import java.nio.file._ import org.http4s.Uri.uri import org.http4s.headers.Range.SubRange import org.http4s.server.middleware.TranslateUri import org.http4s.testing.Http4sLegacyMatchersIO class FileServiceSpec extends Http4sSpec with StaticContentShared with Http4sLegacyMatchersIO { + val defaultSystemPath = test.BuildInfo.test_resourceDirectory.getAbsolutePath val routes = fileService( FileService.Config[IO](new File(getClass.getResource("/").toURI).getPath, testBlocker)) @@ -19,54 +21,155 @@ class FileServiceSpec extends Http4sSpec with StaticContentShared with Http4sLeg val app = TranslateUri("/foo")(routes).orNotFound { - val req = Request[IO](uri = uri("foo/testresource.txt")) + val req = Request[IO](uri = uri("/foo/testresource.txt")) app(req) must returnBody(testResource) app(req) must returnStatus(Status.Ok) } { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) app(req) must returnStatus(Status.NotFound) } } "Return a 200 Ok file" in { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) routes.orNotFound(req) must returnBody(testResource) routes.orNotFound(req) must returnStatus(Status.Ok) } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/space+truckin%27.txt")) + routes.orNotFound(req) must returnStatus(Status.Ok) + } + + "Respect the path prefix" in { + val relativePath = "testresource.txt" + val s0 = fileService( + FileService.Config[IO]( + systemPath = defaultSystemPath, + blocker = testBlocker, + pathPrefix = "/path-prefix" + )) + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + val uri = Uri.unsafeFromString("/path-prefix/" + relativePath) + val req = Request[IO](uri = uri) + s0.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../testresource.txt" + val systemPath = Paths.get(defaultSystemPath).resolve("testDir") + val file = systemPath.resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = systemPath.toString, + blocker = testBlocker + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 on path traversal, even if it's inside the context" in { + val relativePath = "testDir/../testresource.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial system path prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = Paths.get(defaultSystemPath).resolve("test").toString, + blocker = testBlocker + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial path-prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/prefix" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = defaultSystemPath, + pathPrefix = "/prefix", + blocker = testBlocker + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 400 if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultSystemPath).resolve("testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "return files included via symlink" in { + val relativePath = "symlink/org/http4s/server/staticcontent/FileServiceSpec.scala" + val path = Paths.get(defaultSystemPath).resolve(relativePath) + val file = path.toFile + Files.isSymbolicLink(Paths.get(defaultSystemPath).resolve("symlink")) must beTrue + file.exists() must beTrue + val bytes = Chunk.bytes(Files.readAllBytes(path)) + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.Ok) + routes.orNotFound(req) must returnBody(bytes) + } + "Return index.html if request points to a directory" in { - val req = Request[IO](uri = uri("testDir/")) + val req = Request[IO](uri = uri("/testDir/")) val rb = runReq(req) rb._2.as[String] must returnValue("<html>Hello!</html>") rb._2.status must_== Status.Ok } "Not find missing file" in { - val req = Request[IO](uri = uri("testresource.txtt")) + val req = Request[IO](uri = uri("/missing.txt")) routes.orNotFound(req) must returnStatus(Status.NotFound) } "Return a 206 PartialContent file" in { val range = headers.Range(4) - val req = Request[IO](uri = uri("testresource.txt")).withHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).withHeaders(range) routes.orNotFound(req) must returnStatus(Status.PartialContent) routes.orNotFound(req) must returnBody(Chunk.bytes(testResource.toArray.splitAt(4)._2)) } "Return a 206 PartialContent file" in { val range = headers.Range(-4) - val req = Request[IO](uri = uri("testresource.txt")).withHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).withHeaders(range) routes.orNotFound(req) must returnStatus(Status.PartialContent) routes.orNotFound(req) must returnBody( Chunk.bytes(testResource.toArray.splitAt(testResource.size - 4)._2)) } "Return a 206 PartialContent file" in { val range = headers.Range(2, 4) - val req = Request[IO](uri = uri("testresource.txt")).withHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).withHeaders(range) routes.orNotFound(req) must returnStatus(Status.PartialContent) routes.orNotFound(req) must returnBody(Chunk.bytes(testResource.toArray.slice(2, 4 + 1))) // the end number is inclusive in the Range header } @@ -80,13 +183,27 @@ class FileServiceSpec extends Http4sSpec with StaticContentShared with Http4sLeg headers.Range(-200) ) val size = new File(getClass.getResource("/testresource.txt").toURI).length - val reqs = ranges.map(r => Request[IO](uri = uri("testresource.txt")).withHeaders(r)) - + val reqs = ranges.map(r => Request[IO](uri = uri("/testresource.txt")).withHeaders(r)) forall(reqs) { req => routes.orNotFound(req) must returnStatus(Status.RangeNotSatisfiable) routes.orNotFound(req) must returnValue( containsHeader(headers.`Content-Range`(SubRange(0, size - 1), Some(size)))) } } + + "doesn't crash on /" in { + routes.orNotFound(Request[IO](uri = uri("/"))) must returnStatus(Status.NotFound) + } + + "handle a relative system path" in { + val s = fileService(FileService.Config[IO](".", blocker = testBlocker)) + Paths.get(".").resolve("build.sbt").toFile.exists() must beTrue + s.orNotFound(Request[IO](uri = uri("/build.sbt"))) must returnStatus(Status.Ok) + } + + "404 if system path is not found" in { + val s = fileService(FileService.Config[IO]("./does-not-exist", blocker = testBlocker)) + s.orNotFound(Request[IO](uri = uri("/build.sbt"))) must returnStatus(Status.NotFound) + } } }
server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSpec.scala+106 −7 modified@@ -3,6 +3,8 @@ package server package staticcontent import cats.effect.IO +import cats.effect._ +import java.nio.file.Paths import org.http4s.Uri.uri import org.http4s.headers.{`Accept-Encoding`, `If-Modified-Since`} import org.http4s.server.middleware.TranslateUri @@ -11,35 +13,128 @@ import org.http4s.testing.Http4sLegacyMatchersIO class ResourceServiceSpec extends Http4sSpec with StaticContentShared with Http4sLegacyMatchersIO { val config = ResourceService.Config[IO]("", blocker = testBlocker) + val defaultBase = getClass.getResource("/").getPath.toString val routes = resourceService(config) "ResourceService" should { "Respect UriTranslation" in { val app = TranslateUri("/foo")(routes).orNotFound { - val req = Request[IO](uri = uri("foo/testresource.txt")) + val req = Request[IO](uri = uri("/foo/testresource.txt")) app(req) must returnBody(testResource) app(req) must returnStatus(Status.Ok) } { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) app(req) must returnStatus(Status.NotFound) } } "Serve available content" in { - val req = Request[IO](uri = Uri.fromString("testresource.txt").yolo) + val req = Request[IO](uri = Uri.fromString("/testresource.txt").yolo) val rb = routes.orNotFound(req) rb must returnBody(testResource) rb must returnStatus(Status.Ok) } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/space+truckin%27.txt")) + routes.orNotFound(req) must returnStatus(Status.Ok) + } + + "Respect the path prefix" in { + val relativePath = "testresource.txt" + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + blocker = testBlocker, + pathPrefix = "/path-prefix" + )) + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + val uri = Uri.unsafeFromString("/path-prefix/" + relativePath) + val req = Request[IO](uri = uri) + s0.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../testresource.txt" + val basePath = Paths.get(defaultBase).resolve("testDir") + val file = basePath.resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "/testDir", + blocker = testBlocker + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 on path traversal, even if it's inside the context" in { + val relativePath = "testDir/../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial base path prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + blocker = testBlocker + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial path-prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + blocker = testBlocker, + pathPrefix = "/test" + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 400 Not Found if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultBase).resolve("testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "/testDir", + blocker = testBlocker + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + "Try to serve pre-gzipped content if asked to" in { val req = Request[IO]( - uri = Uri.fromString("testresource.txt").yolo, + uri = Uri.fromString("/testresource.txt").yolo, headers = Headers.of(`Accept-Encoding`(ContentCoding.gzip)) ) val rb = resourceService(config.copy(preferGzipped = true)).orNotFound(req) @@ -52,7 +147,7 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared with Http4 "Fallback to un-gzipped file if pre-gzipped version doesn't exist" in { val req = Request[IO]( - uri = Uri.fromString("testresource2.txt").yolo, + uri = Uri.fromString("/testresource2.txt").yolo, headers = Headers.of(`Accept-Encoding`(ContentCoding.gzip)) ) val rb = resourceService(config.copy(preferGzipped = true)).orNotFound(req) @@ -64,15 +159,19 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared with Http4 } "Generate non on missing content" in { - val req = Request[IO](uri = Uri.fromString("testresource.txtt").yolo) + val req = Request[IO](uri = Uri.fromString("/testresource.txtt").yolo) routes.orNotFound(req) must returnStatus(Status.NotFound) } "Not send unmodified files" in { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) .putHeaders(`If-Modified-Since`(HttpDate.MaxValue)) runReq(req)._2.status must_== Status.NotModified } + + "doesn't crash on /" in { + routes.orNotFound(Request[IO](uri = uri("/"))) must returnStatus(Status.NotFound) + } } }
server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSpec.scala+42 −3 modified@@ -3,6 +3,8 @@ package server package staticcontent import cats.effect.IO +import cats.effect._ +import java.nio.file.Paths import org.http4s.Method.{GET, POST} import org.http4s.Uri.uri import org.http4s.server.staticcontent.WebjarService.Config @@ -11,6 +13,8 @@ import org.http4s.testing.Http4sLegacyMatchersIO object WebjarServiceSpec extends Http4sSpec with StaticContentShared with Http4sLegacyMatchersIO { def routes: HttpRoutes[IO] = webjarService(Config[IO](blocker = testBlocker)) + val defaultBase = + test.BuildInfo.test_resourceDirectory.toPath.resolve("META-INF/resources/webjars").toString "The WebjarService" should { "Return a 200 Ok file" in { @@ -29,6 +33,41 @@ object WebjarServiceSpec extends Http4sSpec with StaticContentShared with Http4s rb._2.status must_== Status.Ok } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/deep+purple/machine+head/space+truckin%27.txt")) + routes.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 on a relative link even if it's inside the context" in { + val relativePath = "test-lib/1.0.0/sub/../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../../../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultBase).resolve("test-lib/1.0.0/testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + "Not find missing file" in { val req = Request[IO](uri = uri("/test-lib/1.0.0/doesnotexist.txt")) routes.apply(req).value must returnValue(Option.empty[Response[IO]]) @@ -39,12 +78,12 @@ object WebjarServiceSpec extends Http4sSpec with StaticContentShared with Http4s routes.apply(req).value must returnValue(Option.empty[Response[IO]]) } - "Not find missing version" in { + "Return bad request on missing version" in { val req = Request[IO](uri = uri("/test-lib//doesnotexist.txt")) - routes.apply(req).value must returnValue(Option.empty[Response[IO]]) + routes.orNotFound(req) must returnStatus(Status.BadRequest) } - "Not find missing asset" in { + "Not find blank asset" in { val req = Request[IO](uri = uri("/test-lib/1.0.0/")) routes.apply(req).value must returnValue(Option.empty[Response[IO]]) }
tests/src/test/scala/org/http4s/metrics/MetricsOpsSpec.scala+77 −0 added@@ -0,0 +1,77 @@ +package org.http4s.metrics + +import cats.effect.IO +import cats.implicits._ +import java.util.UUID +import org.http4s._ +import org.scalacheck.{Arbitrary, Gen} +import MetricsOps.classifierFMethodWithOptionallyExcludedPath + +object MetricsOpsSpec { + + private implicit val arbUUID: Arbitrary[UUID] = + Arbitrary(Gen.uuid) +} + +class MetricsOpsSpec extends Http4sSpec { + + import MetricsOpsSpec.arbUUID + + "classifierFMethodWithOptionallyExcludedPath" should { + "properly exclude UUIDs" in prop { + (method: Method, uuid: UUID, excludedValue: String, separator: String) => + val request: Request[IO] = Request[IO]( + method = method, + uri = Uri.unsafeFromString(s"/users/$uuid/comments") + ) + + val excludeUUIDs: String => Boolean = { str: String => + Either + .catchOnly[IllegalArgumentException](UUID.fromString(str)) + .isRight + } + + val classifier: Request[IO] => Option[String] = + classifierFMethodWithOptionallyExcludedPath( + exclude = excludeUUIDs, + excludedValue = excludedValue, + pathSeparator = separator + ) + + val result: Option[String] = + classifier(request) + + val expected: Option[String] = + Some( + method.name + + separator + + "users" + + separator + + excludedValue + + separator + + "comments" + ) + + result ==== expected + } + "return '$method' if the path is '/'" in prop { method: Method => + val request: Request[IO] = Request[IO]( + method = method, + uri = uri"""/""" + ) + + val classifier: Request[IO] => Option[String] = + classifierFMethodWithOptionallyExcludedPath( + _ => true, + "*", + "_" + ) + + val result: Option[String] = + classifier(request) + + result ==== Some(method.name) + } + } + +}
website/src/hugo/content/changelog.md+40 −1 modified@@ -8,6 +8,45 @@ Maintenance branches are merged before each new release. This change log is ordered chronologically, so each release contains all changes described below it. +# v0.21.2 (2020-03-24) + +This release is fully backward compatible with 0.21.1. + +## Security fixes +* [GHSA-66q9-f7ff-mmx6](https://github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6): Fixes a local file inclusion vulnerability in `FileService`, `ResourceService`, and `WebjarService`. + * Request paths with `.`, `..`, or empty segments will now return a 400 in all three services. Combinations of these could formerly be used to escape the configured roots and expose arbitrary local resources. + * Request path segments are now percent-decoded to support resources with reserved characters in the name. + +## Bug fixes + +* [#3261](https://github.com/http4s/http4s/pull/3261): In async-http-client, fixed connection release when body isn't run, as well as thread affinity. + +## Enhancements + +* [#3253](https://github.com/http4s/http4s/pull/3253): Preparation for Dotty support. Should be invisible to end users, but calling out because it touches a lot. + +# v0.20.20 (2020-03-24) + +This release is fully backward compatible with 0.18.25. + +## Security fixes +* [GHSA-66q9-f7ff-mmx6](https://github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6): Fixes a local file inclusion vulnerability in `FileService`, `ResourceService`, and `WebjarService`. + * Request paths with `.`, `..`, or empty segments will now return a 400 in all three services. Combinations of these could formerly be used to escape the configured roots and expose arbitrary local resources. + * Request path segments are now percent-decoded to support resources with reserved characters in the name. + +## Enhancements + +* [#3167](https://github.com/http4s/http4s/pull/3167): Add `MetricsOps.classifierFMethodWithOptionallyExcludedPath`.name. + +# v0.18.26 (2020-03-24) + +This release is fully backward compatible with 0.18.25. + +## Security fixes +* [GHSA-66q9-f7ff-mmx6](https://github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6): Fixes a local file inclusion vulnerability in `FileService`, `ResourceService`, and `WebjarService`. + * Request paths with `.`, `..`, or empty segments will now return a 400 in all three services. Combinations of these could formerly be used to escape the configured roots and expose arbitrary local resources. + * Request path segments are now percent-decoded to support resources with reserved characters in the name. + # v0.21.1 (2020-02-13) This release is fully backward compatible with v0.21.0, and includes all the changes from v0.20.18. @@ -170,7 +209,7 @@ This release is fully compatible with 0.20.16. ## Dependency updates * simpleclient-0.8.1 (Prometheus) - + # v0.18.25 (2020-01-21) ## Bug fixes
752b3f63a05aMerge pull request from GHSA-66q9-f7ff-mmx6
15 files changed · +458 −73
build.sbt+8 −0 modified@@ -136,6 +136,14 @@ lazy val server = libraryProject("server") .settings( description := "Base library for building http4s servers" ) + .settings(BuildInfoPlugin.buildInfoScopedSettings(Test)) + .settings(BuildInfoPlugin.buildInfoDefaultSettings) + .settings( + buildInfoKeys := Seq[BuildInfoKey]( + resourceDirectory in Test, + ), + buildInfoPackage := "org.http4s.server.test" + ) .dependsOn(core, testing % "test->test", theDsl % "test->compile") lazy val prometheusMetrics = libraryProject("prometheus-metrics")
project/build.properties+1 −1 modified@@ -1 +1 @@ -sbt.version=1.3.7 +sbt.version=1.3.8
project/plugins.sbt+1 −1 modified@@ -13,7 +13,7 @@ addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0. addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.7.2") addSbtPlugin("com.lucidchart" % "sbt-scalafmt-coursier" % "1.15") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.4.0") -addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.6.1") +addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0") addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3") addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.3.2") addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.4.2")
server/src/main/scala/org/http4s/server/staticcontent/FileService.scala+47 −15 modified@@ -6,11 +6,19 @@ import cats.data._ import cats.effect._ import cats.implicits._ import java.io.File +import java.nio.file.{LinkOption, Paths} import org.http4s.headers.Range.SubRange import org.http4s.headers._ +import org.http4s.server.middleware.TranslateUri +import org.log4s.getLogger import scala.concurrent.ExecutionContext +import scala.util.control.NoStackTrace +import scala.util.{Failure, Success, Try} +import java.nio.file.NoSuchFileException object FileService { + private[this] val logger = getLogger + type PathCollector[F[_]] = (File, Config[F], Request[F]) => OptionT[F, Response[F]] /** [[org.http4s.server.staticcontent.FileService]] configuration @@ -43,14 +51,46 @@ object FileService { } /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ - private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Effect[F]): HttpRoutes[F] = - Kleisli { - case request if request.pathInfo.startsWith(config.pathPrefix) => - getFile(s"${config.systemPath}/${getSubPath(request.pathInfo, config.pathPrefix)}") - .flatMap(f => config.pathCollector(f, config, request)) - .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) - case _ => OptionT.none + private[staticcontent] def apply[F[_]](config: Config[F])( + implicit F: Effect[F]): HttpRoutes[F] = { + object BadTraversal extends Exception with NoStackTrace + Try(Paths.get(config.systemPath).toRealPath()) match { + case Success(rootPath) => + TranslateUri(config.pathPrefix)(Kleisli { + case request => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(rootPath) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(Uri.decode(segment, plusIsSpace = true)) + } + }) + .semiflatMap(path => F.delay(path.toRealPath(LinkOption.NOFOLLOW_LINKS))) + .collect { case path if path.startsWith(rootPath) => path.toFile } + .flatMap(f => config.pathCollector(f, config, request)) + .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) + .recoverWith { + case _: NoSuchFileException => OptionT.none + case BadTraversal => OptionT.some(Response(Status.BadRequest)) + } + case _ => OptionT.none + } + }) + + case Failure(_: NoSuchFileException) => + logger.error( + s"Could not find root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will return none.") + Kleisli(_ => OptionT.none) + + case Failure(e) => + logger.error(e)( + s"Could not resolve root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will fail with a 500.") + Kleisli(_ => OptionT.pure(Response(Status.InternalServerError))) } + } private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])( implicit F: Sync[F], @@ -124,12 +164,4 @@ object FileService { case _ => OptionT.none } - - // Attempts to sanitize the file location and retrieve the file. Returns None if the file doesn't exist. - private def getFile[F[_]](unsafePath: String)(implicit F: Sync[F]): OptionT[F, File] = - OptionT(F.delay { - val f = new File(Uri.removeDotSegments(unsafePath)) - if (f.exists()) Some(f) - else None - }) }
server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala+51 −13 modified@@ -4,9 +4,16 @@ package staticcontent import cats.data.{Kleisli, OptionT} import cats.effect._ +import cats.implicits._ +import java.nio.file.Paths +import org.http4s.server.middleware.TranslateUri +import org.log4s.getLogger import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success, Try} +import scala.util.control.NoStackTrace object ResourceService { + private[this] val logger = getLogger /** [[org.http4s.server.staticcontent.ResourceService]] configuration * @@ -26,18 +33,49 @@ object ResourceService { preferGzipped: Boolean = false) /** Make a new [[org.http4s.HttpRoutes]] that serves static files. */ - private[staticcontent] def apply[F[_]: Effect: ContextShift](config: Config[F]): HttpRoutes[F] = - Kleisli { - case request if request.pathInfo.startsWith(config.pathPrefix) => - StaticFile - .fromResource( - Uri.removeDotSegments( - s"${config.basePath}/${getSubPath(request.pathInfo, config.pathPrefix)}"), - config.blockingExecutionContext, - Some(request), - preferGzipped = config.preferGzipped - ) - .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) - case _ => OptionT.none + private[staticcontent] def apply[F[_]]( + config: Config[F])(implicit F: Effect[F], cs: ContextShift[F]): HttpRoutes[F] = { + val basePath = if (config.basePath.isEmpty) "/" else config.basePath + object BadTraversal extends Exception with NoStackTrace + + Try(Paths.get(basePath)) match { + case Success(rootPath) => + TranslateUri(config.pathPrefix)(Kleisli { + case request => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(rootPath) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(Uri.decode(segment, plusIsSpace = true)) + } + }) + .collect { + case path if path.startsWith(rootPath) => path + } + .flatMap { path => + StaticFile.fromResource( + path.toString, + config.blockingExecutionContext, + Some(request), + preferGzipped = config.preferGzipped + ) + } + .semiflatMap(config.cacheStrategy.cache(request.pathInfo, _)) + .recoverWith { + case BadTraversal => OptionT.some(Response(Status.BadRequest)) + } + case _ => + OptionT.none + } + }) + + case Failure(e) => + logger.error(e)( + s"Could not get root path from ResourceService config: basePath = ${config.basePath}, pathPrefix = ${config.pathPrefix}. All requests will fail.") + Kleisli(_ => OptionT.pure(Response(Status.InternalServerError))) } + } }
server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala+40 −18 modified@@ -4,7 +4,10 @@ package staticcontent import cats.data.{Kleisli, OptionT} import cats.effect.{ContextShift, Effect} +import cats.implicits._ +import java.nio.file.{Path, Paths} import scala.concurrent.ExecutionContext +import scala.util.control.NoStackTrace /** * Constructs new services to serve assets from Webjars @@ -54,16 +57,32 @@ object WebjarService { * @param config The configuration for this service * @return The HttpRoutes */ - def apply[F[_]: Effect: ContextShift](config: Config[F]): HttpRoutes[F] = Kleisli { - // Intercepts the routes that match webjar asset names - case request if request.method == Method.GET => - OptionT - .pure[F](request.pathInfo) - .map(Uri.removeDotSegments) - .subflatMap(toWebjarAsset) - .filter(config.filter) - .flatMap(serveWebjarAsset(config, request)(_)) - case _ => OptionT.none + def apply[F[_]](config: Config[F])(implicit F: Effect[F], cs: ContextShift[F]): HttpRoutes[F] = { + object BadTraversal extends Exception with NoStackTrace + val Root = Paths.get("") + Kleisli { + // Intercepts the routes that match webjar asset names + case request if request.method == Method.GET => + request.pathInfo.split("/") match { + case Array(head, segments @ _*) if head.isEmpty => + OptionT + .liftF(F.catchNonFatal { + segments.foldLeft(Root) { + case (_, "" | "." | "..") => throw BadTraversal + case (path, segment) => + path.resolve(Uri.decode(segment, plusIsSpace = true)) + } + }) + .subflatMap(toWebjarAsset) + .filter(config.filter) + .flatMap(serveWebjarAsset(config, request)(_)) + .recover { + case BadTraversal => Response(Status.BadRequest) + } + case _ => OptionT.none + } + case _ => OptionT.none + } } /** @@ -72,14 +91,17 @@ object WebjarService { * @param subPath The request path without the prefix * @return The WebjarAsset, or None if it couldn't be mapped */ - private def toWebjarAsset(subPath: String): Option[WebjarAsset] = - Option(subPath) - .map(_.split("/", 4)) - .collect { - case Array("", library, version, asset) - if library.nonEmpty && version.nonEmpty && asset.nonEmpty => - WebjarAsset(library, version, asset) - } + private def toWebjarAsset(p: Path): Option[WebjarAsset] = { + val count = p.getNameCount + if (count > 2) { + val library = p.getName(0).toString + val version = p.getName(1).toString + val asset = p.subpath(2, count) + Some(WebjarAsset(library, version, asset.toString)) + } else { + None + } + } /** * Returns an asset that matched the request if it's found in the webjar path
server/src/test/resources/Dir/partial-prefix.txt+1 −0 added@@ -0,0 +1 @@ +I am useful to test leaks from prefix paths.
server/src/test/resources/META-INF/resources/webjars/deep purple/machine head/space truckin'.txt+5 −0 added@@ -0,0 +1,5 @@ +Come on +Come on +Come on +Let's go +Space truckin'
server/src/test/resources/space truckin'.txt+5 −0 added@@ -0,0 +1,5 @@ +Come on +Come on +Come on +Let's go +Space truckin'
server/src/test/resources/symlink+1 −0 added@@ -0,0 +1 @@ +../scala \ No newline at end of file
server/src/test/resources/test/keep.txt+1 −0 added@@ -0,0 +1 @@ +.
server/src/test/scala/org/http4s/server/staticcontent/FileServiceSpec.scala+124 −13 modified@@ -5,68 +5,166 @@ package staticcontent import cats.effect._ import fs2._ import java.io.File +import java.nio.file._ import org.http4s.Uri.uri import org.http4s.server.middleware.TranslateUri import org.http4s.headers.Range.SubRange class FileServiceSpec extends Http4sSpec with StaticContentShared { - val routes = fileService( - FileService.Config[IO](new File(getClass.getResource("/").toURI).getPath)) + val defaultSystemPath = test.BuildInfo.test_resourceDirectory.getAbsolutePath + val routes = fileService(FileService.Config[IO](defaultSystemPath)) "FileService" should { "Respect UriTranslation" in { val app = TranslateUri("/foo")(routes).orNotFound { - val req = Request[IO](uri = uri("foo/testresource.txt")) + val req = Request[IO](uri = uri("/foo/testresource.txt")) app(req) must returnBody(testResource) app(req) must returnStatus(Status.Ok) } { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) app(req) must returnStatus(Status.NotFound) } } "Return a 200 Ok file" in { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) routes.orNotFound(req) must returnBody(testResource) routes.orNotFound(req) must returnStatus(Status.Ok) } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/space+truckin%27.txt")) + routes.orNotFound(req) must returnStatus(Status.Ok) + } + + "Respect the path prefix" in { + val relativePath = "testresource.txt" + val s0 = fileService( + FileService.Config[IO]( + systemPath = defaultSystemPath, + pathPrefix = "/path-prefix" + )) + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + val uri = Uri.unsafeFromString("/path-prefix/" + relativePath) + val req = Request[IO](uri = uri) + s0.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../testresource.txt" + val systemPath = Paths.get(defaultSystemPath).resolve("testDir") + val file = systemPath.resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = systemPath.toString + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 on path traversal, even if it's inside the context" in { + val relativePath = "testDir/../testresource.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial system path prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = Paths.get(defaultSystemPath).resolve("test").toString + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial path-prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultSystemPath).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/prefix" + relativePath) + val req = Request[IO](uri = uri) + val s0 = fileService( + FileService.Config[IO]( + systemPath = defaultSystemPath, + pathPrefix = "/prefix" + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 400 if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultSystemPath).resolve("testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "return files included via symlink" in { + val relativePath = "symlink/org/http4s/server/staticcontent/FileServiceSpec.scala" + val path = Paths.get(defaultSystemPath).resolve(relativePath) + val file = path.toFile + Files.isSymbolicLink(Paths.get(defaultSystemPath).resolve("symlink")) must beTrue + file.exists() must beTrue + val bytes = Chunk.bytes(Files.readAllBytes(path)) + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.Ok) + routes.orNotFound(req) must returnBody(bytes) + } + "Return index.html if request points to a directory" in { - val req = Request[IO](uri = uri("testDir/")) + val req = Request[IO](uri = uri("/testDir/")) val rb = runReq(req) rb._2.as[String] must returnValue("<html>Hello!</html>") rb._2.status must_== Status.Ok } "Not find missing file" in { - val req = Request[IO](uri = uri("testresource.txtt")) + val req = Request[IO](uri = uri("/missing.txt")) routes.orNotFound(req) must returnStatus(Status.NotFound) } "Return a 206 PartialContent file" in { val range = headers.Range(4) - val req = Request[IO](uri = uri("testresource.txt")).withHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).withHeaders(range) routes.orNotFound(req) must returnStatus(Status.PartialContent) routes.orNotFound(req) must returnBody(Chunk.bytes(testResource.toArray.splitAt(4)._2)) } "Return a 206 PartialContent file" in { val range = headers.Range(-4) - val req = Request[IO](uri = uri("testresource.txt")).withHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).withHeaders(range) routes.orNotFound(req) must returnStatus(Status.PartialContent) routes.orNotFound(req) must returnBody( Chunk.bytes(testResource.toArray.splitAt(testResource.size - 4)._2)) } "Return a 206 PartialContent file" in { val range = headers.Range(2, 4) - val req = Request[IO](uri = uri("testresource.txt")).withHeaders(range) + val req = Request[IO](uri = uri("/testresource.txt")).withHeaders(range) routes.orNotFound(req) must returnStatus(Status.PartialContent) routes.orNotFound(req) must returnBody(Chunk.bytes(testResource.toArray.slice(2, 4 + 1))) // the end number is inclusive in the Range header } @@ -80,14 +178,27 @@ class FileServiceSpec extends Http4sSpec with StaticContentShared { headers.Range(-200) ) val size = new File(getClass.getResource("/testresource.txt").toURI).length - val reqs = ranges.map(r => Request[IO](uri = uri("testresource.txt")).withHeaders(r)) - + val reqs = ranges.map(r => Request[IO](uri = uri("/testresource.txt")).withHeaders(r)) forall(reqs) { req => routes.orNotFound(req) must returnStatus(Status.RangeNotSatisfiable) routes.orNotFound(req) must returnValue( containsHeader(headers.`Content-Range`(SubRange(0, size - 1), Some(size)))) } } - } + "doesn't crash on /" in { + routes.orNotFound(Request[IO](uri = uri("/"))) must returnStatus(Status.NotFound) + } + + "handle a relative system path" in { + val s = fileService(FileService.Config[IO](".")) + Paths.get(".").resolve("build.sbt").toFile.exists() must beTrue + s.orNotFound(Request[IO](uri = uri("/build.sbt"))) must returnStatus(Status.Ok) + } + + "404 if system path is not found" in { + val s = fileService(FileService.Config[IO]("./does-not-exist")) + s.orNotFound(Request[IO](uri = uri("/build.sbt"))) must returnStatus(Status.NotFound) + } + } }
server/src/test/scala/org/http4s/server/staticcontent/ResourceServiceSpec.scala+105 −7 modified@@ -3,6 +3,7 @@ package server package staticcontent import cats.effect._ +import java.nio.file.Paths import org.http4s.headers.{`Accept-Encoding`, `If-Modified-Since`} import org.http4s.server.middleware.TranslateUri import org.http4s.Uri.uri @@ -11,6 +12,7 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { val config = ResourceService.Config[IO]("", blockingExecutionContext = testBlockingExecutionContext) + val defaultBase = getClass.getResource("/").getPath.toString val routes = resourceService(config) "ResourceService" should { @@ -19,28 +21,120 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { val app = TranslateUri("/foo")(routes).orNotFound { - val req = Request[IO](uri = uri("foo/testresource.txt")) + val req = Request[IO](uri = uri("/foo/testresource.txt")) app(req) must returnBody(testResource) app(req) must returnStatus(Status.Ok) } { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) app(req) must returnStatus(Status.NotFound) } } "Serve available content" in { - val req = Request[IO](uri = Uri.fromString("testresource.txt").yolo) + val req = Request[IO](uri = Uri.fromString("/testresource.txt").yolo) val rb = routes.orNotFound(req) rb must returnBody(testResource) rb must returnStatus(Status.Ok) } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/space+truckin%27.txt")) + routes.orNotFound(req) must returnStatus(Status.Ok) + } + + "Respect the path prefix" in { + val relativePath = "testresource.txt" + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + blockingExecutionContext = testBlockingExecutionContext, + pathPrefix = "/path-prefix" + )) + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + val uri = Uri.unsafeFromString("/path-prefix/" + relativePath) + val req = Request[IO](uri = uri) + s0.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../testresource.txt" + val basePath = Paths.get(defaultBase).resolve("testDir") + val file = basePath.resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "/testDir", + blockingExecutionContext = testBlockingExecutionContext + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 on path traversal, even if it's inside the context" in { + val relativePath = "testDir/../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial base path prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + blockingExecutionContext = testBlockingExecutionContext + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 404 Not Found if the request tries to escape the context with a partial path-prefix match" in { + val relativePath = "Dir/partial-prefix.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/test" + relativePath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "", + blockingExecutionContext = testBlockingExecutionContext, + pathPrefix = "/test" + )) + s0.orNotFound(req) must returnStatus(Status.NotFound) + } + + "Return a 400 Not Found if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultBase).resolve("testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + val s0 = resourceService( + ResourceService.Config[IO]( + basePath = "/testDir", + blockingExecutionContext = testBlockingExecutionContext + )) + s0.orNotFound(req) must returnStatus(Status.BadRequest) + } + "Try to serve pre-gzipped content if asked to" in { val req = Request[IO]( - uri = Uri.fromString("testresource.txt").yolo, + uri = Uri.fromString("/testresource.txt").yolo, headers = Headers.of(`Accept-Encoding`(ContentCoding.gzip)) ) val rb = resourceService(config.copy(preferGzipped = true)).orNotFound(req) @@ -53,7 +147,7 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { "Fallback to un-gzipped file if pre-gzipped version doesn't exist" in { val req = Request[IO]( - uri = Uri.fromString("testresource2.txt").yolo, + uri = Uri.fromString("/testresource2.txt").yolo, headers = Headers.of(`Accept-Encoding`(ContentCoding.gzip)) ) val rb = resourceService(config.copy(preferGzipped = true)).orNotFound(req) @@ -65,15 +159,19 @@ class ResourceServiceSpec extends Http4sSpec with StaticContentShared { } "Generate non on missing content" in { - val req = Request[IO](uri = Uri.fromString("testresource.txtt").yolo) + val req = Request[IO](uri = Uri.fromString("/testresource.txtt").yolo) routes.orNotFound(req) must returnStatus(Status.NotFound) } "Not send unmodified files" in { - val req = Request[IO](uri = uri("testresource.txt")) + val req = Request[IO](uri = uri("/testresource.txt")) .putHeaders(`If-Modified-Since`(HttpDate.MaxValue)) runReq(req)._2.status must_== Status.NotModified } + + "doesn't crash on /" in { + routes.orNotFound(Request[IO](uri = uri("/"))) must returnStatus(Status.NotFound) + } } }
server/src/test/scala/org/http4s/server/staticcontent/WebjarServiceSpec.scala+45 −4 modified@@ -3,14 +3,20 @@ package server package staticcontent import cats.effect._ +import java.nio.file.Paths import org.http4s.Method.{GET, POST} import org.http4s.Uri.uri import org.http4s.server.staticcontent.WebjarService.Config object WebjarServiceSpec extends Http4sSpec with StaticContentShared { def routes: HttpRoutes[IO] = - webjarService(Config[IO](blockingExecutionContext = testBlockingExecutionContext)) + webjarService( + Config[IO]( + blockingExecutionContext = testBlockingExecutionContext + )) + val defaultBase = + test.BuildInfo.test_resourceDirectory.toPath.resolve("META-INF/resources/webjars").toString "The WebjarService" should { @@ -30,6 +36,41 @@ object WebjarServiceSpec extends Http4sSpec with StaticContentShared { rb._2.status must_== Status.Ok } + "Decodes path segments" in { + val req = Request[IO](uri = uri("/deep+purple/machine+head/space+truckin%27.txt")) + routes.orNotFound(req) must returnStatus(Status.Ok) + } + + "Return a 400 on a relative link even if it's inside the context" in { + val relativePath = "test-lib/1.0.0/sub/../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 if the request tries to escape the context" in { + val relativePath = "../../../testresource.txt" + val file = Paths.get(defaultBase).resolve(relativePath).toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("/" + relativePath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + + "Return a 400 if the request tries to escape the context with /" in { + val absPath = Paths.get(defaultBase).resolve("test-lib/1.0.0/testresource.txt") + val file = absPath.toFile + file.exists() must beTrue + + val uri = Uri.unsafeFromString("///" + absPath) + val req = Request[IO](uri = uri) + routes.orNotFound(req) must returnStatus(Status.BadRequest) + } + "Not find missing file" in { val req = Request[IO](uri = uri("/test-lib/1.0.0/doesnotexist.txt")) routes.apply(req).value must returnValue(Option.empty[Response[IO]]) @@ -40,12 +81,12 @@ object WebjarServiceSpec extends Http4sSpec with StaticContentShared { routes.apply(req).value must returnValue(Option.empty[Response[IO]]) } - "Not find missing version" in { + "Return bad request on missing version" in { val req = Request[IO](uri = uri("/test-lib//doesnotexist.txt")) - routes.apply(req).value must returnValue(Option.empty[Response[IO]]) + routes.orNotFound(req) must returnStatus(Status.BadRequest) } - "Not find missing asset" in { + "Not find blank asset" in { val req = Request[IO](uri = uri("/test-lib/1.0.0/")) routes.apply(req).value must returnValue(Option.empty[Response[IO]]) }
website/src/hugo/content/changelog.md+23 −1 modified@@ -8,6 +8,28 @@ Maintenance branches are merged before each new release. This change log is ordered chronologically, so each release contains all changes described below it. +# v0.20.20 (2020-03-24) + +This release is fully backward compatible with 0.18.25. + +## Security fixes +* [GHSA-66q9-f7ff-mmx6](https://github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6): Fixes a local file inclusion vulnerability in `FileService`, `ResourceService`, and `WebjarService`. + * Request paths with `.`, `..`, or empty segments will now return a 400 in all three services. Combinations of these could formerly be used to escape the configured roots and expose arbitrary local resources. + * Request path segments are now percent-decoded to support resources with reserved characters in the name. + +## Enhancements + +* [#3167](https://github.com/http4s/http4s/pull/3167): Add `MetricsOps.classifierFMethodWithOptionallyExcludedPath`.name. + +# v0.18.26 (2020-03-24) + +This release is fully backward compatible with 0.18.25. + +## Security fixes +* [GHSA-66q9-f7ff-mmx6](https://github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6): Fixes a local file inclusion vulnerability in `FileService`, `ResourceService`, and `WebjarService`. + * Request paths with `.`, `..`, or empty segments will now return a 400 in all three services. Combinations of these could formerly be used to escape the configured roots and expose arbitrary local resources. + * Request path segments are now percent-decoded to support resources with reserved characters in the name. + # v0.20.19 (2020-02-13) This release is fully backward compatible with 0.20.18. @@ -55,7 +77,7 @@ These are already included in the 0.21 series, but caught up here: ## Dependency updates * simpleclient-0.8.1 (Prometheus) - + # v0.18.25 (2020-01-21) ## Bug fixes
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-66q9-f7ff-mmx6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-5280ghsaADVISORY
- github.com/http4s/http4s/commit/250afddbb2e65b70ca9ddaec9d1eb3aaa56de7ecghsax_refsource_MISCWEB
- github.com/http4s/http4s/commit/752b3f63a05a31d2de4f8706877aa08d6b89efcaghsax_refsource_MISCWEB
- github.com/http4s/http4s/commit/b87f31b2292dabe667bec3b04ce66176c8a3e17bghsax_refsource_MISCWEB
- github.com/http4s/http4s/security/advisories/GHSA-66q9-f7ff-mmx6ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.