Skip to content

Commit 752b3f6

Browse files
authored
Merge pull request from GHSA-66q9-f7ff-mmx6
Fix GHSA-66q9-f7ff-mmx6 for 0.20.x
2 parents c253f05 + 4e23069 commit 752b3f6

File tree

15 files changed

+458
-73
lines changed

15 files changed

+458
-73
lines changed

Diff for: build.sbt

+8
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ lazy val server = libraryProject("server")
136136
.settings(
137137
description := "Base library for building http4s servers"
138138
)
139+
.settings(BuildInfoPlugin.buildInfoScopedSettings(Test))
140+
.settings(BuildInfoPlugin.buildInfoDefaultSettings)
141+
.settings(
142+
buildInfoKeys := Seq[BuildInfoKey](
143+
resourceDirectory in Test,
144+
),
145+
buildInfoPackage := "org.http4s.server.test"
146+
)
139147
.dependsOn(core, testing % "test->test", theDsl % "test->compile")
140148

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

Diff for: project/build.properties

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

Diff for: project/plugins.sbt

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.
1313
addSbtPlugin("com.github.tkawachi" % "sbt-doctest" % "0.7.2")
1414
addSbtPlugin("com.lucidchart" % "sbt-scalafmt-coursier" % "1.15")
1515
addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.4.0")
16-
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.6.1")
16+
addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "0.7.0")
1717
addSbtPlugin("com.typesafe.sbt" % "sbt-ghpages" % "0.6.3")
1818
addSbtPlugin("com.typesafe.sbt" % "sbt-site" % "1.3.2")
1919
addSbtPlugin("com.typesafe.sbt" % "sbt-twirl" % "1.4.2")

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

+47-15
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import cats.data._
66
import cats.effect._
77
import cats.implicits._
88
import java.io.File
9+
import java.nio.file.{LinkOption, Paths}
910
import org.http4s.headers.Range.SubRange
1011
import org.http4s.headers._
12+
import org.http4s.server.middleware.TranslateUri
13+
import org.log4s.getLogger
1114
import scala.concurrent.ExecutionContext
15+
import scala.util.control.NoStackTrace
16+
import scala.util.{Failure, Success, Try}
17+
import java.nio.file.NoSuchFileException
1218

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

1624
/** [[org.http4s.server.staticcontent.FileService]] configuration
@@ -43,14 +51,46 @@ object FileService {
4351
}
4452

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

5595
private def filesOnly[F[_]](file: File, config: Config[F], req: Request[F])(
5696
implicit F: Sync[F],
@@ -124,12 +164,4 @@ object FileService {
124164

125165
case _ => OptionT.none
126166
}
127-
128-
// Attempts to sanitize the file location and retrieve the file. Returns None if the file doesn't exist.
129-
private def getFile[F[_]](unsafePath: String)(implicit F: Sync[F]): OptionT[F, File] =
130-
OptionT(F.delay {
131-
val f = new File(Uri.removeDotSegments(unsafePath))
132-
if (f.exists()) Some(f)
133-
else None
134-
})
135167
}

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

+51-13
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ 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.log4s.getLogger
711
import scala.concurrent.ExecutionContext
12+
import scala.util.{Failure, Success, Try}
13+
import scala.util.control.NoStackTrace
814

915
object ResourceService {
16+
private[this] val logger = getLogger
1017

1118
/** [[org.http4s.server.staticcontent.ResourceService]] configuration
1219
*
@@ -26,18 +33,49 @@ object ResourceService {
2633
preferGzipped: Boolean = false)
2734

2835
/** Make a new [[org.http4s.HttpRoutes]] that serves static files. */
29-
private[staticcontent] def apply[F[_]: Effect: ContextShift](config: Config[F]): HttpRoutes[F] =
30-
Kleisli {
31-
case request if request.pathInfo.startsWith(config.pathPrefix) =>
32-
StaticFile
33-
.fromResource(
34-
Uri.removeDotSegments(
35-
s"${config.basePath}/${getSubPath(request.pathInfo, config.pathPrefix)}"),
36-
config.blockingExecutionContext,
37-
Some(request),
38-
preferGzipped = config.preferGzipped
39-
)
40-
.semiflatMap(config.cacheStrategy.cache(request.pathInfo, _))
41-
case _ => OptionT.none
36+
private[staticcontent] def apply[F[_]](
37+
config: Config[F])(implicit F: Effect[F], cs: ContextShift[F]): HttpRoutes[F] = {
38+
val basePath = if (config.basePath.isEmpty) "/" else config.basePath
39+
object BadTraversal extends Exception with NoStackTrace
40+
41+
Try(Paths.get(basePath)) match {
42+
case Success(rootPath) =>
43+
TranslateUri(config.pathPrefix)(Kleisli {
44+
case request =>
45+
request.pathInfo.split("/") match {
46+
case Array(head, segments @ _*) if head.isEmpty =>
47+
OptionT
48+
.liftF(F.catchNonFatal {
49+
segments.foldLeft(rootPath) {
50+
case (_, "" | "." | "..") => throw BadTraversal
51+
case (path, segment) =>
52+
path.resolve(Uri.decode(segment, plusIsSpace = true))
53+
}
54+
})
55+
.collect {
56+
case path if path.startsWith(rootPath) => path
57+
}
58+
.flatMap { path =>
59+
StaticFile.fromResource(
60+
path.toString,
61+
config.blockingExecutionContext,
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)))
4279
}
80+
}
4381
}

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

+40-18
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ package staticcontent
44

55
import cats.data.{Kleisli, OptionT}
66
import cats.effect.{ContextShift, Effect}
7+
import cats.implicits._
8+
import java.nio.file.{Path, Paths}
79
import scala.concurrent.ExecutionContext
10+
import scala.util.control.NoStackTrace
811

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

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

84106
/**
85107
* 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)