Skip to content

Commit 250afdd

Browse files
authored
Merge pull request from GHSA-66q9-f7ff-mmx6
Fix GHSA-66q9-f7ff-mmx6 for 0.21.x
2 parents e8f4bb6 + 0a46b00 commit 250afdd

File tree

16 files changed

+632
-69
lines changed

16 files changed

+632
-69
lines changed

Diff for: build.sbt

+8
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ lazy val server = libraryProject("server")
116116
.settings(
117117
description := "Base library for building http4s servers"
118118
)
119+
.settings(BuildInfoPlugin.buildInfoScopedSettings(Test))
120+
.settings(BuildInfoPlugin.buildInfoDefaultSettings)
121+
.settings(
122+
buildInfoKeys := Seq[BuildInfoKey](
123+
resourceDirectory in Test,
124+
),
125+
buildInfoPackage := "org.http4s.server.test"
126+
)
119127
.dependsOn(core, testing % "test->test", theDsl % "test->compile")
120128

121129
lazy val prometheusMetrics = libraryProject("prometheus-metrics")

Diff for: core/src/main/scala/org/http4s/metrics/MetricsOps.scala

+81-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package org.http4s.metrics
22

3-
import org.http4s.{Method, Status}
3+
import cats.Foldable
4+
import cats.implicits._
5+
import org.http4s.{Method, Request, Status, Uri}
46

57
/**
68
* Describes an algebra capable of writing metrics to a metrics registry
@@ -57,6 +59,84 @@ trait MetricsOps[F[_]] {
5759
classifier: Option[String]): F[Unit]
5860
}
5961

62+
object MetricsOps {
63+
64+
/**
65+
* Given an exclude function, return a 'classifier' function, i.e. for application in
66+
* org.http4s.server/client.middleware.Metrics#apply.
67+
*
68+
* Let's say you want a classifier that excludes integers since your paths consist of:
69+
* * GET /users/{integer} = GET_users_*
70+
* * POST /users = POST_users
71+
* * PUT /users/{integer} = PUT_users_*
72+
* * DELETE /users/{integer} = DELETE_users_*
73+
*
74+
* In such a case, we could use:
75+
*
76+
* classifierFMethodWithOptionallyExcludedPath(
77+
* exclude = { str: String => scala.util.Try(str.toInt).isSuccess },
78+
* excludedValue = "*",
79+
* intercalateValue = "_"
80+
* )
81+
*
82+
*
83+
* Chris Davenport notes the following on performance considerations of exclude's function value:
84+
*
85+
* > It's worth noting that this runs on every segment of a path. So note that if an intermediate Throwables with
86+
* > Stack traces is known and discarded, there may be a performance penalty, such as the above example with Try(str.toInt).
87+
* > I benchmarked some approaches and regex matches should generally be preferred over Throwable's
88+
* > in this position.
89+
*
90+
* @param exclude For a given String, namely a path value, determine whether the value gets excluded.
91+
* @param excludedValue Indicates the String value to be supplied for an excluded path's field.
92+
* @param pathSeparator Value to use for separating the metrics fields' values
93+
* @return Request[F] => Option[String]
94+
*/
95+
def classifierFMethodWithOptionallyExcludedPath[F[_]](
96+
exclude: String => Boolean,
97+
excludedValue: String = "*",
98+
pathSeparator: String = "_"
99+
): Request[F] => Option[String] = { request: Request[F] =>
100+
val initial: String = request.method.name
101+
102+
val pathList: List[String] =
103+
requestToPathList(request)
104+
105+
val minusExcluded: List[String] = pathList.map { value: String =>
106+
if (exclude(value)) excludedValue else value
107+
}
108+
109+
val result: String =
110+
minusExcluded match {
111+
case Nil => initial
112+
case nonEmpty @ _ :: _ =>
113+
initial + pathSeparator + Foldable[List]
114+
.intercalate(nonEmpty, pathSeparator)
115+
}
116+
117+
Some(result)
118+
}
119+
120+
// The following was copied from
121+
// https://github.com/http4s/http4s/blob/v0.20.17/dsl/src/main/scala/org/http4s/dsl/impl/Path.scala#L56-L64,
122+
// and then modified.
123+
private def requestToPathList[F[_]](request: Request[F]): List[String] = {
124+
val str: String = request.pathInfo
125+
126+
if (str == "" || str == "/")
127+
Nil
128+
else {
129+
val segments = str.split("/", -1)
130+
// .head is safe because split always returns non-empty array
131+
val segments0 = if (segments.head == "") segments.drop(1) else segments
132+
val reversed: List[String] =
133+
segments0.foldLeft[List[String]](Nil)((path, seg) => Uri.decode(seg) :: path)
134+
reversed.reverse
135+
}
136+
}
137+
138+
}
139+
60140
/** Describes the type of abnormal termination*/
61141
sealed trait TerminationType
62142

Diff for: project/plugins.sbt

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5.
1111
addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.2.12")
1212
addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.9.6")
1313
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.0")
14-
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.6.4")
14+
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0")
1515
addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3")
1616
addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.4.0")
1717
addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.5.0")

Diff for: server/src/main/scala/org/http4s/server/staticcontent/FileService.scala

+46-15
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ import cats.data.{Kleisli, NonEmptyList, OptionT}
66
import cats.effect.{Blocker, ContextShift, Sync}
77
import cats.implicits._
88
import java.io.File
9+
import java.nio.file.NoSuchFileException
10+
import java.nio.file.{LinkOption, Paths}
911
import org.http4s.headers.Range.SubRange
1012
import org.http4s.headers._
13+
import org.http4s.server.middleware.TranslateUri
14+
import org.log4s.getLogger
15+
import scala.util.control.NoStackTrace
16+
import scala.util.{Failure, Success, Try}
1117

1218
object FileService {
19+
private[this] val logger = getLogger
20+
1321
type PathCollector[F[_]] = (File, Config[F], Request[F]) => OptionT[F, Response[F]]
1422

1523
/** [[org.http4s.server.staticcontent.FileService]] configuration
@@ -42,14 +50,45 @@ object FileService {
4250
}
4351

4452
/** Make a new [[org.http4s.HttpRoutes]] that serves static files. */
45-
private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Sync[F]): HttpRoutes[F] =
46-
Kleisli {
47-
case request if request.pathInfo.startsWith(config.pathPrefix) =>
48-
OptionT(getFile(s"${config.systemPath}/${getSubPath(request.pathInfo, config.pathPrefix)}"))
49-
.flatMap(f => config.pathCollector(f, config, request))
50-
.semiflatMap(config.cacheStrategy.cache(request.pathInfo, _))
51-
case _ => OptionT.none
53+
private[staticcontent] def apply[F[_]](config: Config[F])(implicit F: Sync[F]): HttpRoutes[F] = {
54+
object BadTraversal extends Exception with NoStackTrace
55+
Try(Paths.get(config.systemPath).toRealPath()) match {
56+
case Success(rootPath) =>
57+
TranslateUri(config.pathPrefix)(Kleisli {
58+
case request =>
59+
request.pathInfo.split("/") match {
60+
case Array(head, segments @ _*) if head.isEmpty =>
61+
OptionT
62+
.liftF(F.catchNonFatal {
63+
segments.foldLeft(rootPath) {
64+
case (_, "" | "." | "..") => throw BadTraversal
65+
case (path, segment) =>
66+
path.resolve(Uri.decode(segment, plusIsSpace = true))
67+
}
68+
})
69+
.semiflatMap(path => F.delay(path.toRealPath(LinkOption.NOFOLLOW_LINKS)))
70+
.collect { case path if path.startsWith(rootPath) => path.toFile }
71+
.flatMap(f => config.pathCollector(f, config, request))
72+
.semiflatMap(config.cacheStrategy.cache(request.pathInfo, _))
73+
.recoverWith {
74+
case _: NoSuchFileException => OptionT.none
75+
case BadTraversal => OptionT.some(Response(Status.BadRequest))
76+
}
77+
case _ => OptionT.none
78+
}
79+
})
80+
81+
case Failure(_: NoSuchFileException) =>
82+
logger.error(
83+
s"Could not find root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will return none.")
84+
Kleisli(_ => OptionT.none)
85+
86+
case Failure(e) =>
87+
logger.error(e)(
88+
s"Could not resolve root path from FileService config: systemPath = ${config.systemPath}, pathPrefix = ${config.pathPrefix}. All requests will fail with a 500.")
89+
Kleisli(_ => OptionT.pure(Response(Status.InternalServerError)))
5290
}
91+
}
5392

5493
private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])(
5594
implicit F: Sync[F],
@@ -115,12 +154,4 @@ object FileService {
115154
}
116155
case _ => F.pure(None)
117156
}
118-
119-
// Attempts to sanitize the file location and retrieve the file. Returns None if the file doesn't exist.
120-
private def getFile[F[_]](unsafePath: String)(implicit F: Sync[F]): F[Option[File]] =
121-
F.delay {
122-
val f = new File(Uri.removeDotSegments(unsafePath))
123-
if (f.exists()) Some(f)
124-
else None
125-
}
126157
}

Diff for: server/src/main/scala/org/http4s/server/staticcontent/ResourceService.scala

+51-13
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@ package staticcontent
44

55
import cats.data.{Kleisli, OptionT}
66
import cats.effect.{Blocker, ContextShift, Sync}
7+
import cats.implicits._
8+
import java.nio.file.Paths
9+
import org.http4s.server.middleware.TranslateUri
10+
import org.log4s.getLogger
11+
import scala.util.control.NoStackTrace
12+
import scala.util.{Failure, Success, Try}
713

814
object ResourceService {
15+
private[this] val logger = getLogger
916

1017
/** [[org.http4s.server.staticcontent.ResourceService]] configuration
1118
*
@@ -25,18 +32,49 @@ object ResourceService {
2532
preferGzipped: Boolean = false)
2633

2734
/** Make a new [[org.http4s.HttpRoutes]] that serves static files. */
28-
private[staticcontent] def apply[F[_]: Sync: ContextShift](config: Config[F]): HttpRoutes[F] =
29-
Kleisli {
30-
case request if request.pathInfo.startsWith(config.pathPrefix) =>
31-
StaticFile
32-
.fromResource(
33-
Uri.removeDotSegments(
34-
s"${config.basePath}/${getSubPath(request.pathInfo, config.pathPrefix)}"),
35-
config.blocker,
36-
Some(request),
37-
preferGzipped = config.preferGzipped
38-
)
39-
.semiflatMap(config.cacheStrategy.cache(request.pathInfo, _))
40-
case _ => OptionT.none
35+
private[staticcontent] def apply[F[_]](
36+
config: Config[F])(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = {
37+
val basePath = if (config.basePath.isEmpty) "/" else config.basePath
38+
object BadTraversal extends Exception with NoStackTrace
39+
40+
Try(Paths.get(basePath)) match {
41+
case Success(rootPath) =>
42+
TranslateUri(config.pathPrefix)(Kleisli {
43+
case request =>
44+
request.pathInfo.split("/") match {
45+
case Array(head, segments @ _*) if head.isEmpty =>
46+
OptionT
47+
.liftF(F.catchNonFatal {
48+
segments.foldLeft(rootPath) {
49+
case (_, "" | "." | "..") => throw BadTraversal
50+
case (path, segment) =>
51+
path.resolve(Uri.decode(segment, plusIsSpace = true))
52+
}
53+
})
54+
.collect {
55+
case path if path.startsWith(rootPath) => path
56+
}
57+
.flatMap { path =>
58+
StaticFile.fromResource(
59+
path.toString,
60+
config.blocker,
61+
Some(request),
62+
preferGzipped = config.preferGzipped
63+
)
64+
}
65+
.semiflatMap(config.cacheStrategy.cache(request.pathInfo, _))
66+
.recoverWith {
67+
case BadTraversal => OptionT.some(Response(Status.BadRequest))
68+
}
69+
case _ =>
70+
OptionT.none
71+
}
72+
})
73+
74+
case Failure(e) =>
75+
logger.error(e)(
76+
s"Could not get root path from ResourceService config: basePath = ${config.basePath}, pathPrefix = ${config.pathPrefix}. All requests will fail.")
77+
Kleisli(_ => OptionT.pure(Response(Status.InternalServerError)))
4178
}
79+
}
4280
}

Diff for: server/src/main/scala/org/http4s/server/staticcontent/WebjarService.scala

+40-18
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ package staticcontent
44

55
import cats.data.{Kleisli, OptionT}
66
import cats.effect.{Blocker, ContextShift, Sync}
7+
import cats.implicits._
8+
import java.nio.file.{Path, Paths}
9+
import scala.util.control.NoStackTrace
710

811
/**
912
* Constructs new services to serve assets from Webjars
@@ -52,16 +55,32 @@ object WebjarService {
5255
* @param config The configuration for this service
5356
* @return The HttpRoutes
5457
*/
55-
def apply[F[_]: Sync: ContextShift](config: Config[F]): HttpRoutes[F] = Kleisli {
56-
// Intercepts the routes that match webjar asset names
57-
case request if request.method == Method.GET =>
58-
val uri = Uri.removeDotSegments(request.pathInfo)
59-
toWebjarAsset(uri) match {
60-
case Some(asset) if config.filter(asset) =>
61-
serveWebjarAsset(config, request)(asset)
62-
case _ => OptionT.none
63-
}
64-
case _ => OptionT.none
58+
def apply[F[_]](config: Config[F])(implicit F: Sync[F], cs: ContextShift[F]): HttpRoutes[F] = {
59+
object BadTraversal extends Exception with NoStackTrace
60+
val Root = Paths.get("")
61+
Kleisli {
62+
// Intercepts the routes that match webjar asset names
63+
case request if request.method == Method.GET =>
64+
request.pathInfo.split("/") match {
65+
case Array(head, segments @ _*) if head.isEmpty =>
66+
OptionT
67+
.liftF(F.catchNonFatal {
68+
segments.foldLeft(Root) {
69+
case (_, "" | "." | "..") => throw BadTraversal
70+
case (path, segment) =>
71+
path.resolve(Uri.decode(segment, plusIsSpace = true))
72+
}
73+
})
74+
.subflatMap(toWebjarAsset)
75+
.filter(config.filter)
76+
.flatMap(serveWebjarAsset(config, request)(_))
77+
.recover {
78+
case BadTraversal => Response(Status.BadRequest)
79+
}
80+
case _ => OptionT.none
81+
}
82+
case _ => OptionT.none
83+
}
6584
}
6685

6786
/**
@@ -70,14 +89,17 @@ object WebjarService {
7089
* @param subPath The request path without the prefix
7190
* @return The WebjarAsset, or None if it couldn't be mapped
7291
*/
73-
private def toWebjarAsset(subPath: String): Option[WebjarAsset] =
74-
Option(subPath)
75-
.map(_.split("/", 4))
76-
.collect {
77-
case Array("", library, version, asset)
78-
if library.nonEmpty && version.nonEmpty && asset.nonEmpty =>
79-
WebjarAsset(library, version, asset)
80-
}
92+
private def toWebjarAsset(p: Path): Option[WebjarAsset] = {
93+
val count = p.getNameCount
94+
if (count > 2) {
95+
val library = p.getName(0).toString
96+
val version = p.getName(1).toString
97+
val asset = p.subpath(2, count)
98+
Some(WebjarAsset(library, version, asset.toString))
99+
} else {
100+
None
101+
}
102+
}
81103

82104
/**
83105
* Returns an asset that matched the request if it's found in the webjar path

Diff for: server/src/test/resources/Dir/partial-prefix.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
I am useful to test leaks from prefix paths.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Come on
2+
Come on
3+
Come on
4+
Let's go
5+
Space truckin'

Diff for: server/src/test/resources/space truckin'.txt

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Come on
2+
Come on
3+
Come on
4+
Let's go
5+
Space truckin'

Diff for: server/src/test/resources/symlink

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../scala

Diff for: server/src/test/resources/test/keep.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.

0 commit comments

Comments
 (0)