This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/main by this push:
     new 2bb837f55 Compare path elements when checking that a served file is 
below the base directory (#1218)
2bb837f55 is described below

commit 2bb837f5543cc35ddbe61d51fdb64deade1398f9
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 1 11:54:59 2026 +0100

    Compare path elements when checking that a served file is below the base 
directory (#1218)
    
    * compare path elements when checking that a file is below the served 
directory
    
    Motivation:
    `checkIsSafeDescendant` compared the canonical location of the requested
    file with the canonical path of the served directory as a plain string
    prefix. A path such as `/var/www-private/secret` has `/var/www` as a string
    prefix without being contained in it, so a symbolic link inside the served
    directory that resolves to such a sibling directory passed the check and the
    file was served. The segment filter in `safeJoinPaths` does not catch this,
    because no path segment is suspicious; only canonicalization moves the
    location out of the directory.
    
    Modification:
    Compare the two canonical paths element by element via `java.nio.file.Path`
    instead of as strings. That keeps the base directory itself accepted, which
    the directory listing of `getFromBrowseableDirectory` relies on.
    
    Result:
    Only files that are really below the served directory are served, and a
    symbolic link to a sibling directory is rejected with the existing warning
    regardless of how the sibling is named.
    
    Tests:
    - sbt "http-tests/testOnly 
org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSymlinkSpec"
 - pass, 1 new test that serves a symlink to a sibling directory named after 
the served one; it fails without the change
    - sbt http-tests/test - pass
    - sbt http/mimaReportBinaryIssues - pass
    - sbt http/scalafmt http-tests/Test/scalafmt - clean
    
    References:
    None - tightens the containment check for file and resource directives
    
    * reject paths that cannot exist instead of erroring, document the 
element-wise containment
    
    Motivation:
    Review of the containment change found that Paths.get can throw an
    unchecked InvalidPathException where the previous String.startsWith
    never threw: on Windows a decoded segment such as `a<b` passes
    safeJoinPaths and File.getCanonicalPath but not the stricter
    WindowsPathParser, turning a previously clean rejection into a 500
    that skips the traversal warning and breaks rejection-based route
    chaining. A NUL byte in a segment (`%00`) had the same effect on every
    platform even before this PR, via the IOException that
    File.getCanonicalPath throws for it. The scaladoc above
    safeDirectoryChildPath also still described the string-prefix
    containment semantics that the previous commit removed as insecure,
    and the symlink spec wrote file content that no assertion reads.
    
    Modification:
    Catch InvalidPathException and IOException in checkIsSafeDescendant
    and treat such paths as not contained, producing the existing warning
    and rejection; no file with such a name can exist, so nothing
    servable is lost. Restate the scaladoc containment contract as
    element-wise comparison and note that Windows canonicalization does
    not resolve NTFS symbolic links or junctions. Create the sibling
    fixture file empty in the symlink spec. Rebased onto main.
    
    Result:
    A request segment that no file-system path may contain is rejected
    with the traversal warning instead of escaping to the exception
    handler as a 500, on all platforms; the documented contract matches
    the implementation.
    
    Tests:
    - sbt "http-tests/testOnly 
org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSpec 
org.apache.pekko.http.scaladsl.server.directives.FileAndResourceDirectivesSymlinkSpec"
 - pass (52 tests); new test rejects a %00 segment and asserts the traversal 
warning; verified it fails with the fix stashed (unhandled IOException)
    - sbt http/mimaReportBinaryIssues - pass
    - native scalafmt run on the three changed files - clean
    
    References:
    None - hardens the containment check follow-up from review
---
 .../directives/FileAndResourceDirectivesSpec.scala | 12 ++++++++++
 .../FileAndResourceDirectivesSymlinkSpec.scala     | 23 +++++++++++++++++++
 .../directives/FileAndResourceDirectives.scala     | 26 +++++++++++++++++-----
 3 files changed, 56 insertions(+), 5 deletions(-)

diff --git 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSpec.scala
 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSpec.scala
index de3f1e70f..92b13af23 100644
--- 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSpec.scala
+++ 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSpec.scala
@@ -195,6 +195,18 @@ class FileAndResourceDirectivesSpec extends RoutingSpec 
with Inspectors with Ins
       shouldReject("..%c0%af", warnings = 0)
       shouldReject("..%c1%9c", warnings = 0)
     }
+    "reject requests whose path can never name a file" in {
+      // a percent-encoded NUL decodes into the path segment, but no 
file-system path may contain it, so
+      // canonicalization throws; that must surface as a rejection, not as an 
error escaping to the exception handler
+      def route(uri: String) =
+        mapRequestContext(_.withUnmatchedPath(Path("/" + uri))) { 
_getFromDirectory("someDir") }
+
+      EventFilter.warning(pattern = ".* points to a location that is not part 
of .*", occurrences = 1).intercept {
+        Get() ~> route("na%00ive.txt") ~> check {
+          handled shouldEqual false
+        }
+      }
+    }
     "return the file content with the MediaType matching the file extension" 
in {
       Get("fileA.txt") ~> _getFromDirectory("someDir") ~> check {
         mediaType shouldEqual `text/plain`
diff --git 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSymlinkSpec.scala
 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSymlinkSpec.scala
index 42f9324bf..9fa202a96 100644
--- 
a/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSymlinkSpec.scala
+++ 
b/http-tests/src/test/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectivesSymlinkSpec.scala
@@ -41,9 +41,21 @@ class FileAndResourceDirectivesSymlinkSpec extends 
RoutingSpec
     Paths.get(dirWithLink.getAbsolutePath, "linked-dir"),
     new File(testRoot, "subDirectory").toPath.toAbsolutePath)
 
+  // a sibling of the served directory whose name has the name of the served 
directory as a prefix
+  val siblingDir = new File(tempDir.toFile, "dirWithLink-private")
+  siblingDir.mkdir()
+  val siblingFile = new File(siblingDir, "secret.txt")
+  Files.createFile(siblingFile.toPath)
+  val siblingSymlink = Files.createSymbolicLink(
+    Paths.get(dirWithLink.getAbsolutePath, "linked-sibling"),
+    siblingDir.toPath.toAbsolutePath)
+
   override def afterAll(): Unit = {
     super.afterAll()
     Files.deleteIfExists(symlink)
+    Files.deleteIfExists(siblingSymlink)
+    Files.deleteIfExists(siblingFile.toPath)
+    Files.deleteIfExists(siblingDir.toPath)
     Files.deleteIfExists(dirWithLink.toPath)
     Files.deleteIfExists(tempDir)
   }
@@ -69,5 +81,16 @@ class FileAndResourceDirectivesSymlinkSpec extends 
RoutingSpec
         }
       }
     }
+
+    "not follow symbolic links into a sibling directory whose name starts with 
the served directory" in {
+      Files.isSymbolicLink(siblingSymlink) shouldBe true
+      // the canonical location of the file is 
`<tmp>/dirWithLink-private/secret.txt`, which has the canonical
+      // path of the served directory, `<tmp>/dirWithLink`, as a string prefix
+      EventFilter.warning(pattern = ".* points to a location that is not part 
of .*", occurrences = 1).intercept {
+        Get("linked-sibling/secret.txt") ~> _getFromDirectory() ~> check {
+          handled shouldBe false
+        }
+      }
+    }
   }
 }
diff --git 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectives.scala
 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectives.scala
index 9d8882e2b..3692011db 100644
--- 
a/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectives.scala
+++ 
b/http/src/main/scala/org/apache/pekko/http/scaladsl/server/directives/FileAndResourceDirectives.scala
@@ -16,6 +16,7 @@ package directives
 
 import java.io.{ File, FileNotFoundException, IOException, InputStream }
 import java.net.{ JarURLConnection, URL, URLConnection }
+import java.nio.file.{ InvalidPathException, Paths }
 
 import scala.annotation.tailrec
 import scala.jdk.CollectionConverters._
@@ -235,9 +236,12 @@ object FileAndResourceDirectives extends 
FileAndResourceDirectives {
    *    to files containing one of those characters on a file-system that 
allows those characters in file names
    *    (e.g. backslash on posix).
    *  - Resulting paths are checked to be "contained" in the base directory. 
"Contained" means that the canonical location
-   *    of the file (according to File.getCanonicalPath) has the canonical 
version of the basePath as a prefix. The exact
-   *    semantics depend on the implementation of `File.getCanonicalPath` that 
may or may not resolve symbolic links and
-   *    similar structures depending on the OS and the JDK implementation of 
file system accesses.
+   *    of the file (according to File.getCanonicalPath) has the canonical 
version of the basePath as a path-element
+   *    prefix, compared element by element rather than as strings, so that a 
sibling directory whose name merely starts
+   *    with the base path's name is not treated as contained. The exact 
semantics depend on the implementation of
+   *    `File.getCanonicalPath` that may or may not resolve symbolic links and 
similar structures depending on the OS and
+   *    the JDK implementation of file system accesses; on Windows in 
particular it does not resolve NTFS symbolic links
+   *    and junctions, so a link out of the base directory is not detected 
there.
    */
   private def safeDirectoryChildPath(basePath: String, path: Uri.Path, log: 
LoggingAdapter,
       separator: Char = File.separatorChar): String =
@@ -271,9 +275,21 @@ object FileAndResourceDirectives extends 
FileAndResourceDirectives {
   private def checkIsSafeDescendant(basePath: String, finalPath: String, log: 
LoggingAdapter): String = {
     val baseFile = new File(basePath)
     val finalFile = new File(finalPath)
-    val canonicalFinalPath = finalFile.getCanonicalPath
 
-    if (!canonicalFinalPath.startsWith(baseFile.getCanonicalPath)) {
+    // compared element by element instead of as plain strings: 
`/var/www-private/secret` has the canonical path of
+    // `/var/www` as a string prefix without being contained in that 
directory, which canonicalization can produce
+    // for a symbolic link that points at a sibling directory
+    val canonicalFinalPath =
+      try {
+        val canonical = finalFile.getCanonicalPath
+        if 
(Paths.get(canonical).startsWith(Paths.get(baseFile.getCanonicalPath))) 
canonical else ""
+      } catch {
+        // a segment can contain characters that no file-system path may hold 
(NUL on all platforms, '<' and
+        // similar on Windows): getCanonicalPath and Paths.get throw on those, 
and such a file cannot exist anyway
+        case _: InvalidPathException | _: IOException => ""
+      }
+
+    if (canonicalFinalPath.isEmpty) {
       log.warning("[{}] points to a location that is not part of [{}]. This 
might be a directory traversal attempt.",
         finalFile, baseFile)
       ""


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to