Skip to content
Permalink
Browse files Browse the repository at this point in the history
Merge pull request from GHSA-66q9-f7ff-mmx6
Fix GHSA-66q9-f7ff-mmx6 for 0.21.x
  • Loading branch information
rossabaker committed Mar 25, 2020
2 parents e8f4bb6 + 0a46b00 commit 250afdd
Show file tree
Hide file tree
Showing 16 changed files with 632 additions and 69 deletions.
8 changes: 8 additions & 0 deletions build.sbt
Expand Up @@ -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")
Expand Down
82 changes: 81 additions & 1 deletion core/src/main/scala/org/http4s/metrics/MetricsOps.scala
@@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion project/plugins.sbt
Expand Up @@ -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")
Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
}
}
Expand Up @@ -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
*
Expand All @@ -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)))
}
}
}
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

/**
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/src/test/resources/Dir/partial-prefix.txt
@@ -0,0 +1 @@
I am useful to test leaks from prefix paths.
@@ -0,0 +1,5 @@
Come on
Come on
Come on
Let's go
Space truckin'
5 changes: 5 additions & 0 deletions server/src/test/resources/space truckin'.txt
@@ -0,0 +1,5 @@
Come on
Come on
Come on
Let's go
Space truckin'
1 change: 1 addition & 0 deletions server/src/test/resources/symlink
1 change: 1 addition & 0 deletions server/src/test/resources/test/keep.txt
@@ -0,0 +1 @@
.

0 comments on commit 250afdd

Please sign in to comment.