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&b.txt") + (body should not).include("a&b.txt") + body should include("a'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 { | |""".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("&") + case '<' => sb.append("<") + case '>' => sb.append(">") + case '"' => sb.append(""") + case '\'' => sb.append("'") + 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("../\n".format(path.substring(0, secondToLastSlash))) + sb.append("../\n".format(escapeHtml(path.substring(0, secondToLastSlash)))) } def lastModified(file: File) = DateTime(file.lastModified).toIsoLikeDateTimeString - def start(name: String) = - sb.append("").append(name).append("") - .append(" " * (maxNameLen - name.length)) + def start(name: String) = { + val escapedName = escapeHtml(name) + sb.append("").append(escapedName).append( + "") + 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) = {