Skip to content

Commit 418c735

Browse files
authored
http (fix): Verify relative paths in StaticContent server (#3897)
1 parent 46fc14c commit 418c735

File tree

2 files changed

+86
-6
lines changed

2 files changed

+86
-6
lines changed

airframe-http-netty/src/test/scala/wvlet/airframe/http/netty/StaticContentTest.scala

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
package wvlet.airframe.http.netty
1515

1616
import wvlet.airframe.http.client.SyncClient
17-
import wvlet.airframe.http.{Endpoint, Http, HttpHeader, HttpMessage, RxRouter, StaticContent}
17+
import wvlet.airframe.http.{Endpoint, Http, HttpHeader, HttpMessage, HttpStatus, RxRouter, StaticContent}
1818
import wvlet.airspec.AirSpec
1919

2020
object StaticContentTest extends AirSpec {
@@ -41,4 +41,52 @@ object StaticContentTest extends AirSpec {
4141
resp.contentString shouldContain "<html>"
4242
resp.contentType shouldBe Some("text/html")
4343
}
44+
45+
test("reject path traversal attacks") { (client: SyncClient) =>
46+
// Test absolute path
47+
val respAbsolute = client.sendSafe(Http.GET("//../../etc/passwd"))
48+
respAbsolute.status shouldBe HttpStatus.Forbidden_403
49+
50+
// Test directory traversal using ../
51+
val respTraversal = client.sendSafe(Http.GET("/../../../etc/passwd"))
52+
respTraversal.status shouldBe HttpStatus.Forbidden_403
53+
54+
// Test directory traversal using // (double slashes)
55+
val respDoubleSlash = client.sendSafe(Http.GET("//etc/passwd"))
56+
respDoubleSlash.status shouldBe HttpStatus.Forbidden_403
57+
}
58+
59+
test("reject path traversal attacks with normalized paths") { (client: SyncClient) =>
60+
// Test normalized path traversal
61+
val respNormalized = client.sendSafe(Http.GET("/foo/./bar/../../etc/passwd"))
62+
respNormalized.status shouldBe HttpStatus.NotFound_404
63+
}
64+
65+
test("reject absolute path traversal") { (client: SyncClient) =>
66+
// Test absolute path traversal
67+
val respAbsolute = client.sendSafe(Http.GET("/////etc/passwd"))
68+
respAbsolute.status shouldBe HttpStatus.Forbidden_403
69+
70+
// Test absolute path traversal with double slashes
71+
val respDoubleSlash = client.sendSafe(Http.GET("///etc/passwd"))
72+
respDoubleSlash.status shouldBe HttpStatus.Forbidden_403
73+
}
74+
75+
test("reject relative parent path traversal") { (client: SyncClient) =>
76+
// Test relative path traversal
77+
val respRelative = client.sendSafe(Http.GET("..//etc/passwd"))
78+
respRelative.status shouldBe HttpStatus.Forbidden_403
79+
80+
// Test relative path traversal with double slashes
81+
val respRelativeDoubleSlash = client.sendSafe(Http.GET("..//..//etc/passwd"))
82+
respRelativeDoubleSlash.status shouldBe HttpStatus.Forbidden_403
83+
84+
// Test relative path traversal with single dot
85+
val respRelativeSingleDot = client.sendSafe(Http.GET("../etc/passwd"))
86+
respRelativeSingleDot.status shouldBe HttpStatus.Forbidden_403
87+
88+
// Test relative path traversal with double dots
89+
val respRelativeDoubleDot = client.sendSafe(Http.GET("..//..//etc/passwd"))
90+
respRelativeDoubleDot.status shouldBe HttpStatus.Forbidden_403
91+
}
4492
}

airframe-http/.jvm/src/main/scala/wvlet/airframe/http/StaticContent.scala

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,52 @@
1212
* limitations under the License.
1313
*/
1414
package wvlet.airframe.http
15-
import java.io.File
15+
import java.io.{File, IOException}
1616
import java.net.URL
17-
1817
import wvlet.airframe.control.Control
1918
import wvlet.airframe.http.HttpMessage.Response
2019
import wvlet.log.LogSupport
2120
import wvlet.log.io.{IOUtil, Resource}
2221

22+
import java.nio.file.Paths
2323
import scala.annotation.tailrec
24+
import scala.util.Try
2425

2526
/**
2627
* Helper for returning static contents
2728
*/
2829
object StaticContent extends LogSupport {
2930

3031
trait ResourceType {
32+
def basePath: String
3133
def find(relativePath: String): Option[URL]
34+
35+
private val canonicalBasePath: Try[String] = Try(new File(basePath).getCanonicalPath)
36+
37+
// Helper to check if a potential resource path is truly within the base path
38+
protected def isPathInsideBase(resourceFile: File): Boolean = {
39+
canonicalBasePath
40+
.flatMap { cbPath =>
41+
Try(resourceFile.getCanonicalPath).map { rcPath =>
42+
// Check if the resource's canonical path starts with the base's canonical path,
43+
// ensuring it's truly contained within. Handle the separator correctly.
44+
rcPath.startsWith(cbPath) &&
45+
(rcPath.length == cbPath.length || // Exactly the base path itself
46+
rcPath.charAt(cbPath.length) == File.separatorChar || // Starts with base path + separator
47+
cbPath == "/") // Special case for root base path
48+
}
49+
}.recover { case e: IOException =>
50+
// Error getting canonical paths likely indicates issues (permissions, non-existent?)
51+
logger.warn(s"Failed to get canonical path for comparison: ${e.getMessage}", e)
52+
false
53+
}.getOrElse(false) // Default to false if any Try failed
54+
}
3255
}
3356

3457
case class FileResource(basePath: String) extends ResourceType {
3558
override def find(relativePath: String): Option[URL] = {
3659
val f = new File(s"${basePath}/${relativePath}")
37-
if (f.exists()) {
60+
if (f.exists() && f.isFile() && isPathInsideBase(f)) {
3861
Some(f.toURI.toURL)
3962
} else {
4063
None
@@ -63,7 +86,16 @@ object StaticContent extends LogSupport {
6386
}
6487
}
6588

66-
loop(0, path.split("/").toList)
89+
// Check for null characters
90+
if (path.contains("\u0000")) {
91+
false
92+
} else if (path.startsWith("/") || path.isEmpty || path.contains("//")) {
93+
false
94+
} else if (Try(Paths.get(path)).isFailure) {
95+
false
96+
} else {
97+
loop(0, path.split("/").toList.filter(_.nonEmpty))
98+
}
6799
}
68100

69101
private def findContentType(filePath: String): String = {
@@ -116,7 +148,7 @@ object StaticContent extends LogSupport {
116148

117149
import wvlet.airframe.http.StaticContent.*
118150

119-
case class StaticContent(resourcePaths: List[StaticContent.ResourceType] = List.empty) {
151+
case class StaticContent(resourcePaths: List[StaticContent.ResourceType] = List.empty) extends LogSupport {
120152
def fromDirectory(basePath: String): StaticContent = {
121153
this.copy(resourcePaths = FileResource(basePath) :: resourcePaths)
122154
}

0 commit comments

Comments
 (0)