Skip to content

Commit b87f31b

Browse files
authored
Merge pull request from GHSA-66q9-f7ff-mmx6
Fix GHSA-66q9-f7ff-mmx6
2 parents 8be70c6 + 1c48c57 commit b87f31b

File tree

16 files changed

+439
-70
lines changed

16 files changed

+439
-70
lines changed

Diff for: build.sbt

+8
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ lazy val server = libraryProject("server")
6363
.settings(
6464
description := "Base library for building http4s servers"
6565
)
66+
.settings(BuildInfoPlugin.buildInfoScopedSettings(Test))
67+
.settings(BuildInfoPlugin.buildInfoDefaultSettings)
68+
.settings(
69+
buildInfoKeys := Seq[BuildInfoKey](
70+
resourceDirectory in Test,
71+
),
72+
buildInfoPackage := "org.http4s.server.test"
73+
)
6674
.dependsOn(core, testing % "test->test", theDsl % "test->compile")
6775

6876
lazy val serverMetrics = libraryProject("server-metrics")

Diff for: project/build.properties

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
sbt.version=1.2.3
1+
sbt.version=1.3.8

Diff for: project/plugins.sbt

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "4.0.2")
44
addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.7.2")
55
addSbtPlugin("com.lucidchart" % "sbt-scalafmt-coursier" % "1.15")
66
addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0")
7-
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.1.18")
7+
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0")
88
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.3")
99
addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.3.15")
1010
addSbtPlugin("io.gatling" % "gatling-sbt" % "2.2.2")

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

+49-15
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,22 @@ package staticcontent
44

55
import cats.data._
66
import cats.effect._
7+
import cats.implicits._
78
import java.io.File
9+
import java.nio.file.{LinkOption, Paths}
810
import org.http4s.headers.Range.SubRange
911
import org.http4s.headers._
12+
import org.http4s.server.middleware.TranslateUri
13+
import org.http4s.util.UrlCodingUtils.urlDecode
14+
import org.log4s.getLogger
1015
import scala.concurrent.ExecutionContext
16+
import scala.util.control.NoStackTrace
17+
import scala.util.{Failure, Success, Try}
18+
import java.nio.file.NoSuchFileException
1119

1220
object FileService {
21+
private[this] val logger = getLogger
22+
1323
type PathCollector[F[_]] = (File, Config[F], Request[F]) => OptionT[F, Response[F]]
1424

1525
/** [[org.http4s.server.staticcontent.FileService]] configuration
@@ -42,14 +52,46 @@ object FileService {
4252
}
4353

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

5496
private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])(
5597
implicit F: Sync[F]): OptionT[F, Response[F]] =
@@ -95,12 +137,4 @@ object FileService {
95137

96138
case _ => OptionT.none
97139
}
98-
99-
// Attempts to sanitize the file location and retrieve the file. Returns None if the file doesn't exist.
100-
private def getFile[F[_]](unsafePath: String)(implicit F: Sync[F]): OptionT[F, File] =
101-
OptionT(F.delay {
102-
val f = new File(PathNormalizer.removeDotSegments(unsafePath))
103-
if (f.exists()) Some(f)
104-
else None
105-
})
106140
}

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

+51-12
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ package staticcontent
44

55
import cats.data.{Kleisli, OptionT}
66
import cats.effect._
7+
import cats.implicits._
8+
import java.nio.file.Paths
9+
import org.http4s.server.middleware.TranslateUri
10+
import org.http4s.util.UrlCodingUtils.urlDecode
11+
import org.log4s.getLogger
712
import scala.concurrent.ExecutionContext
13+
import scala.util.{Failure, Success, Try}
14+
import scala.util.control.NoStackTrace
815

916
object ResourceService {
17+
private[this] val logger = getLogger
1018

1119
/** [[org.http4s.server.staticcontent.ResourceService]] configuration
1220
*
@@ -26,17 +34,48 @@ object ResourceService {
2634
preferGzipped: Boolean = false)
2735

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

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

+41-18
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ package staticcontent
44

55
import cats.data.{Kleisli, OptionT}
66
import cats.effect.Effect
7+
import cats.implicits._
8+
import java.nio.file.{Path, Paths}
9+
import org.http4s.util.UrlCodingUtils.urlDecode
10+
import scala.util.control.NoStackTrace
711

812
/**
913
* Constructs new services to serve assets from Webjars
@@ -51,16 +55,32 @@ object WebjarService {
5155
* @param config The configuration for this service
5256
* @return The HttpService
5357
*/
54-
def apply[F[_]: Effect](config: Config[F]): HttpService[F] = Kleisli {
55-
// Intercepts the routes that match webjar asset names
56-
case request if request.method == Method.GET =>
57-
OptionT
58-
.pure[F](request.pathInfo)
59-
.map(PathNormalizer.removeDotSegments)
60-
.subflatMap(toWebjarAsset)
61-
.filter(config.filter)
62-
.flatMap(serveWebjarAsset(config, request)(_))
63-
case _ => OptionT.none
58+
def apply[F[_]](config: Config[F])(implicit F: Effect[F]): HttpService[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(urlDecode(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+
}
6484
}
6585

6686
/**
@@ -69,14 +89,17 @@ object WebjarService {
6989
* @param subPath The request path without the prefix
7090
* @return The WebjarAsset, or None if it couldn't be mapped
7191
*/
72-
private def toWebjarAsset(subPath: String): Option[WebjarAsset] =
73-
Option(subPath)
74-
.map(_.split("/", 4))
75-
.collect {
76-
case Array("", library, version, asset)
77-
if library.nonEmpty && version.nonEmpty && asset.nonEmpty =>
78-
WebjarAsset(library, version, asset)
79-
}
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+
}
80103

81104
/**
82105
* 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)