VYPR
Critical severityNVD Advisory· Published Mar 25, 2020· Updated Aug 4, 2024

Local file inclusion vulnerability in http4s

CVE-2020-5280

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.

PackageAffected versionsPatched versions
org.http4s:http4s-server_2.12Maven
< 0.18.260.18.26
org.http4s:http4s-server_2.12Maven
>= 0.19.0, < 0.20.200.20.20
org.http4s:http4s-server_2.12Maven
>= 0.21.0, < 0.21.20.21.2

Affected products

2

Patches

3
b87f31b2292d

Merge pull request from GHSA-66q9-f7ff-mmx6

https://github.com/http4s/http4sRoss A. BakerMar 25, 2020via ghsa
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
    
250afddbb2e6

Merge pull request from GHSA-66q9-f7ff-mmx6

https://github.com/http4s/http4sRoss A. BakerMar 25, 2020via ghsa
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
    
752b3f63a05a

Merge pull request from GHSA-66q9-f7ff-mmx6

https://github.com/http4s/http4sRoss A. BakerMar 25, 2020via ghsa
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

News mentions

0

No linked articles in our index yet.