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 1379d7b85 escape html in FileAndResourceDirectives (#1191)
1379d7b85 is described below

commit 1379d7b854fcd6a8733608eab2de91199875a103
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Jul 31 10:03:49 2026 +0100

    escape html in FileAndResourceDirectives (#1191)
    
    * escape html in FileAndResourceDirectives
    
    * Update FileAndResourceDirectivesSpec.scala
    
    * Update FileAndResourceDirectives.scala
    
    * Update FileAndResourceDirectives.scala
---
 .../directives/FileAndResourceDirectivesSpec.scala | 20 ++++++++++++
 .../directives/FileAndResourceDirectives.scala     | 36 +++++++++++++++++++---
 2 files changed, 51 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 5d7afb139..77a262b03 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
@@ -535,6 +535,26 @@ class FileAndResourceDirectivesSpec extends RoutingSpec 
with Inspectors with Ins
       shouldReject("..%c1%9c", warnings = 0)
     }
 
+    "escape HTML special characters in file names to prevent XSS" in {
+      // Only test characters that are valid in filenames on all platforms 
(Windows, macOS, Linux).
+      // Windows forbids " < > | : * ? \ / in filenames.
+      val dir = Files.createTempDirectory("pekko-xss-test").toFile
+      try {
+        writeAllText("ampersand", new File(dir, "a&b.txt"))
+        writeAllText("apostrophe", new File(dir, "a'b.txt"))
+        Get() ~> 
withSettings(settings)(listDirectoryContents(dir.getAbsolutePath)) ~> check {
+          val body = responseAs[String]
+          body should include("a&amp;b.txt")
+          (body should not).include("a&b.txt")
+          body should include("a&#39;b.txt")
+          (body should not).include("a'b.txt")
+        }
+      } finally {
+        dir.listFiles().foreach(_.delete())
+        dir.delete()
+      }
+    }
+
   }
 
   def prep(s: String) = s.stripMarginWithNewline("\n")
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 c425568c8..19a6c9955 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
@@ -400,6 +400,23 @@ object DirectoryListing {
       |</html>
       |""".stripMarginWithNewline("\n").split('$')
 
+  private def escapeHtml(s: String): String = {
+    val sb = new java.lang.StringBuilder(s.length + 16)
+    var i = 0
+    while (i < s.length) {
+      s.charAt(i) match {
+        case '&'  => sb.append("&amp;")
+        case '<'  => sb.append("&lt;")
+        case '>'  => sb.append("&gt;")
+        case '"'  => sb.append("&quot;")
+        case '\'' => sb.append("&#39;")
+        case c    => sb.append(c)
+      }
+      i += 1
+    }
+    sb.toString
+  }
+
   def directoryMarshaller(renderVanityFooter: Boolean): 
ToEntityMarshaller[DirectoryListing] =
     Marshaller.StringMarshaller.wrap(MediaTypes.`text/html`) { listing =>
       val DirectoryListing(path, isRoot, files) = listing
@@ -412,15 +429,24 @@ object DirectoryListing {
       def maxNameLength(seq: Seq[(File, String)]) = if (seq.isEmpty) 0 else 
seq.map(_._2.length).max
       val maxNameLen = math.max(maxNameLength(directoryFilesAndNames) + 1, 
maxNameLength(fileFilesAndNames))
       val sb = new java.lang.StringBuilder
-      
sb.append(html(0)).append(path).append(html(1)).append(path).append(html(2))
+      val escapedPath = escapeHtml(path)
+      
sb.append(html(0)).append(escapedPath).append(html(1)).append(escapedPath).append(html(2))
       if (!isRoot) {
         val secondToLastSlash = path.lastIndexOf('/', path.lastIndexOf('/', 
path.length - 1) - 1)
-        sb.append("<a href=\"%s/\">../</a>\n".format(path.substring(0, 
secondToLastSlash)))
+        sb.append("<a 
href=\"%s/\">../</a>\n".format(escapeHtml(path.substring(0, 
secondToLastSlash))))
       }
       def lastModified(file: File) = 
DateTime(file.lastModified).toIsoLikeDateTimeString
-      def start(name: String) =
-        sb.append("<a href=\"").append(path + 
name).append("\">").append(name).append("</a>")
-          .append(" " * (maxNameLen - name.length))
+      def start(name: String) = {
+        val escapedName = escapeHtml(name)
+        sb.append("<a 
href=\"").append(escapeHtml(path)).append(escapedName).append("\">").append(escapedName).append(
+          "</a>")
+        var padding = maxNameLen - name.length
+        while (padding > 0) {
+          sb.append(' ')
+          padding -= 1
+        }
+        sb
+      }
       def renderDirectory(file: File, name: String) =
         start(name + '/').append("        
").append(lastModified(file)).append('\n')
       def renderFile(file: File, name: String) = {


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

Reply via email to