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-connectors.git


The following commit(s) were added to refs/heads/main by this push:
     new 10868afad try to prevent traversal issues in file and ftp connectors 
(#1807)
10868afad is described below

commit 10868afad8e80cfb3cc17fc4ab60fa163128c349
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Aug 16 11:51:17 2026 +0100

    try to prevent traversal issues in file and ftp connectors (#1807)
    
    * try to prevent traversal issues in ftp connector
    
    * Update model.scala
    
    * Update CommonFtpOperations.scala
    
    * Update CommonFtpOperations.scala
    
    * try windows testing
    
    * Update check-build-test.yml
    
    * fix tests
    
    * try to make file tests work on windows
    
    * Update check-build-test.yml
    
    * test issues
    
    * scalafmt
    
    * stop some tests running on windows
    
    * archive paths
    
    * windows test issues
---
 .github/workflows/check-build-test.yml             |  45 ++++++++++
 .../pekko/stream/connectors/file/model.scala       |  41 +++++++++
 .../test/java/docs/javadsl/FileTailSourceTest.java |   4 +
 .../test/java/docs/javadsl/LogRotatorSinkTest.java |  99 +++++++++++---------
 .../src/test/scala/docs/scaladsl/ArchiveSpec.scala |  27 +++---
 .../docs/scaladsl/FileTailSourceExtrasSpec.scala   |  17 +++-
 .../scala/docs/scaladsl/LogRotatorSinkSpec.scala   |  12 ++-
 .../test/scala/docs/scaladsl/TarArchiveSpec.scala  |  64 ++++++-------
 .../file/impl/archive/TarArchiveEntrySpec.scala    |  62 +++++++++++++
 .../file/impl/archive/ZipArchiveMetadataSpec.scala |  65 ++++++++++++++
 .../connectors/ftp/impl/CommonFtpOperations.scala  |  60 ++++++++++++-
 .../connectors/ftp/impl/SftpOperations.scala       |  11 ++-
 .../ftp/impl/CommonFtpOperationsSpec.scala         | 100 +++++++++++++++++++++
 13 files changed, 510 insertions(+), 97 deletions(-)

diff --git a/.github/workflows/check-build-test.yml 
b/.github/workflows/check-build-test.yml
index c95af520b..ddd35cdd9 100644
--- a/.github/workflows/check-build-test.yml
+++ b/.github/workflows/check-build-test.yml
@@ -185,3 +185,48 @@ jobs:
       - name: Print logs on failure
         if: failure()
         run: find . -name "*.log" -exec ./scripts/cat-log.sh {} \;
+
+  connectors-windows:
+    runs-on: windows-latest
+    if: github.repository == 'apache/pekko-connectors'
+
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - { connector: file }
+          - { connector: ftp, test_cmd: 'ftp/testOnly 
org.apache.pekko.stream.connectors.ftp.impl.*' }
+
+    env:
+      JAVA_OPTS: -Xms2G -Xmx3G -Xss2M -XX:ReservedCodeCacheSize=256M 
-Dfile.encoding=UTF-8
+
+    steps:
+      - name: Checkout
+        uses: actions/[email protected]
+        with:
+          fetch-tags: true
+          fetch-depth: 0
+
+      - name: Setup Java 17
+        uses: actions/setup-java@v5
+        with:
+          distribution: temurin
+          java-version: 17
+
+      - name: Install sbt
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
+
+      - name: Cache Coursier cache
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
v6.4.7
+
+      - name: ${{ matrix.connector }}
+        shell: bash
+        env:
+          CONNECTOR: ${{ matrix.connector }}
+          TEST_CMD: ${{ matrix.test_cmd }}
+        run: |-
+          if [ -n "$TEST_CMD" ]; then
+            sbt "+${TEST_CMD}"
+          else
+            sbt "+${CONNECTOR}/test"
+          fi
diff --git 
a/file/src/main/scala/org/apache/pekko/stream/connectors/file/model.scala 
b/file/src/main/scala/org/apache/pekko/stream/connectors/file/model.scala
index 780d58f69..c444aa2b8 100644
--- a/file/src/main/scala/org/apache/pekko/stream/connectors/file/model.scala
+++ b/file/src/main/scala/org/apache/pekko/stream/connectors/file/model.scala
@@ -17,6 +17,44 @@ import java.time.Instant
 import java.time.temporal.ChronoField
 import java.util.Objects
 
+/**
+ * INTERNAL API
+ *
+ * Validation for path traversal sequences in archive entry names (Zip Slip / 
Tar Slip).
+ *
+ * Both forward slash (`/`) and backslash (`\`) are rejected as path 
separators because:
+ * - ZIP files use forward slashes per the ZIP Application Note (PKWARE) 
section 4.4.17
+ * - TAR file names use forward slashes per POSIX.1 (IEEE Std 1003.1) and the 
USTAR format
+ *   (POSIX.1-2001 / IEEE Std 1003.1-2001, extended by POSIX.1-2008 pax 
headers)
+ * - On Windows, backslashes are path separators and would be interpreted by 
the filesystem
+ *   API when extracting, even though they are not valid separators in the 
archive formats.
+ *   Rejecting them prevents path traversal via crafted archives on Windows 
hosts.
+ */
+private[file] object ArchivePathTraversalValidation {
+
+  /**
+   * Validate that an archive path segment does not contain traversal 
sequences.
+   * Rejects segments containing `..` as a path component, absolute paths, and
+   * backslashes (which Windows treats as path separators during extraction).
+   *
+   * @param value     the path segment to validate
+   * @param fieldName the name of the field for error messages
+   * @throws IllegalArgumentException if the segment contains traversal 
sequences
+   */
+  def validate(value: String, fieldName: String): Unit = {
+    require(value != null, s"$fieldName must not be null")
+    // Reject absolute paths
+    require(!value.startsWith("/"), s"$fieldName must not be an absolute path: 
'$value'")
+    // Reject backslashes — not valid in ZIP/TAR specs, but treated as path 
separators on Windows
+    require(!value.contains('\\'), s"$fieldName must not contain backslashes: 
'$value'")
+    // Reject path traversal sequences: ".." as a standalone segment
+    val segments = value.split('/')
+    require(
+      !segments.contains(".."),
+      s"$fieldName must not contain path traversal sequences: '$value'")
+  }
+}
+
 final class ArchiveMetadata private (
     val filePath: String)
 
@@ -26,6 +64,7 @@ object ArchiveMetadata {
 }
 
 final case class ZipArchiveMetadata(name: String) {
+  ArchivePathTraversalValidation.validate(name, "Zip entry name")
   def getName() = name
 }
 object ZipArchiveMetadata {
@@ -129,9 +168,11 @@ object TarArchiveMetadata {
       require(
         value.length <= 154,
         "File path prefix must be between 1 and 154 characters long")
+      ArchivePathTraversalValidation.validate(value, "File path prefix")
     }
     require(filePathName.length >= 0 && filePathName.length <= 99,
       s"File path name must be between 0 and 99 characters long, was 
${filePathName.length}")
+    ArchivePathTraversalValidation.validate(filePathName, "File path name")
 
     new TarArchiveMetadata(filePathPrefix,
       filePathName,
diff --git a/file/src/test/java/docs/javadsl/FileTailSourceTest.java 
b/file/src/test/java/docs/javadsl/FileTailSourceTest.java
index 916ac73f1..499ed6a0b 100644
--- a/file/src/test/java/docs/javadsl/FileTailSourceTest.java
+++ b/file/src/test/java/docs/javadsl/FileTailSourceTest.java
@@ -17,6 +17,8 @@ import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.nio.file.StandardOpenOption.APPEND;
 import static java.nio.file.StandardOpenOption.WRITE;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
 
 import com.google.common.jimfs.Configuration;
 import com.google.common.jimfs.Jimfs;
@@ -134,6 +136,7 @@ public class FileTailSourceTest {
   }
 
   @Test
+  @DisabledOnOs(OS.WINDOWS)
   public void willCompleteStreamIfFileIsDeleted() throws Exception {
     final Path path = fs.getPath("/file");
     Files.writeString(path, "a\n", UTF_8);
@@ -180,6 +183,7 @@ public class FileTailSourceTest {
   }
 
   @Test
+  @DisabledOnOs(OS.WINDOWS)
   public void willCompleteStreamIfFileIsIdle() throws Exception {
     final Path path = fs.getPath("/file");
     Files.writeString(path, "a\n", UTF_8);
diff --git a/file/src/test/java/docs/javadsl/LogRotatorSinkTest.java 
b/file/src/test/java/docs/javadsl/LogRotatorSinkTest.java
index 905cba9d7..fb522c230 100644
--- a/file/src/test/java/docs/javadsl/LogRotatorSinkTest.java
+++ b/file/src/test/java/docs/javadsl/LogRotatorSinkTest.java
@@ -15,7 +15,6 @@ package docs.javadsl;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
-import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.LocalDateTime;
@@ -93,7 +92,7 @@ public class LogRotatorSinkTest {
   @Test
   public void timeBased() throws Exception {
     // #time
-    final Path destinationDir = FileSystems.getDefault().getPath("/tmp");
+    final Path destinationDir = Files.createTempDirectory("log-rotation-test");
     final DateTimeFormatter formatter = 
DateTimeFormatter.ofPattern("'stream-'yyyy-MM-dd_HH'.log'");
 
     Creator<Function<ByteString, Optional<Path>>> timeBasedTriggerCreator =
@@ -114,47 +113,59 @@ public class LogRotatorSinkTest {
         LogRotatorSink.createFromFunction(timeBasedTriggerCreator);
     // #time
 
-    CompletionStage<Done> fileSizeCompletion =
-        Source.from(List.of("test1", "test2", "test3", "test4", "test5", 
"test6"))
-            .map(ByteString::fromString)
-            .runWith(timeBasedSink, system);
-
-    assertEquals(
-        Done.getInstance(), fileSizeCompletion.toCompletableFuture().get(2, 
TimeUnit.SECONDS));
-
-    /*
-    // #sample
-    import org.apache.pekko.stream.connectors.file.javadsl.LogRotatorSink;
-
-    Creator<Function<ByteString, Optional<Path>>> triggerFunctionCreator = ...;
-
-    // #sample
-    */
-    Creator<Function<ByteString, Optional<Path>>> triggerFunctionCreator = 
timeBasedTriggerCreator;
-
-    Source<ByteString, NotUsed> source =
-        Source.from(List.of("test1", "test2", "test3", "test4", "test5", 
"test6"))
-            .map(ByteString::fromString);
-    // #sample
-    CompletionStage<Done> completion =
-        Source.from(List.of("test1", "test2", "test3", "test4", "test5", 
"test6"))
-            .map(ByteString::fromString)
-            
.runWith(LogRotatorSink.createFromFunction(triggerFunctionCreator), system);
-
-    // GZip compressing the data written
-    CompletionStage<Done> compressedCompletion =
-        source.runWith(
-            LogRotatorSink.withSinkFactory(
-                triggerFunctionCreator,
-                path ->
-                    Flow.of(ByteString.class)
-                        .via(Compression.gzip())
-                        .toMat(FileIO.toPath(path), Keep.right())),
-            system);
-    // #sample
-
-    assertEquals(Done.getInstance(), completion.toCompletableFuture().get(2, 
TimeUnit.SECONDS));
-    assertEquals(
-        Done.getInstance(), compressedCompletion.toCompletableFuture().get(2, 
TimeUnit.SECONDS));
+    try {
+      CompletionStage<Done> fileSizeCompletion =
+          Source.from(List.of("test1", "test2", "test3", "test4", "test5", 
"test6"))
+              .map(ByteString::fromString)
+              .runWith(timeBasedSink, system);
+
+      assertEquals(
+          Done.getInstance(), fileSizeCompletion.toCompletableFuture().get(2, 
TimeUnit.SECONDS));
+
+      /*
+      // #sample
+      import org.apache.pekko.stream.connectors.file.javadsl.LogRotatorSink;
+
+      Creator<Function<ByteString, Optional<Path>>> triggerFunctionCreator = 
...;
+
+      // #sample
+      */
+      Creator<Function<ByteString, Optional<Path>>> triggerFunctionCreator =
+          timeBasedTriggerCreator;
+
+      Source<ByteString, NotUsed> source =
+          Source.from(List.of("test1", "test2", "test3", "test4", "test5", 
"test6"))
+              .map(ByteString::fromString);
+      // #sample
+      CompletionStage<Done> completion =
+          Source.from(List.of("test1", "test2", "test3", "test4", "test5", 
"test6"))
+              .map(ByteString::fromString)
+              
.runWith(LogRotatorSink.createFromFunction(triggerFunctionCreator), system);
+
+      // GZip compressing the data written
+      CompletionStage<Done> compressedCompletion =
+          source.runWith(
+              LogRotatorSink.withSinkFactory(
+                  triggerFunctionCreator,
+                  path ->
+                      Flow.of(ByteString.class)
+                          .via(Compression.gzip())
+                          .toMat(FileIO.toPath(path), Keep.right())),
+              system);
+      // #sample
+
+      assertEquals(Done.getInstance(), completion.toCompletableFuture().get(2, 
TimeUnit.SECONDS));
+      assertEquals(
+          Done.getInstance(), 
compressedCompletion.toCompletableFuture().get(2, TimeUnit.SECONDS));
+    } finally {
+      // Clean up temp directory and all files created by the trigger
+      java.util.Comparator<java.io.File> reverse =
+          (a, b) -> b.getAbsolutePath().compareTo(a.getAbsolutePath());
+      java.io.File[] children = destinationDir.toFile().listFiles();
+      if (children != null) {
+        
java.util.Arrays.stream(children).sorted(reverse).forEach(java.io.File::delete);
+      }
+      destinationDir.toFile().delete();
+    }
   }
 }
diff --git a/file/src/test/scala/docs/scaladsl/ArchiveSpec.scala 
b/file/src/test/scala/docs/scaladsl/ArchiveSpec.scala
index 558084477..859d09d9d 100644
--- a/file/src/test/scala/docs/scaladsl/ArchiveSpec.scala
+++ b/file/src/test/scala/docs/scaladsl/ArchiveSpec.scala
@@ -163,22 +163,27 @@ class ArchiveSpec
         val target: Path = // ???
           // #zip-reader
           Files.createTempDirectory("pekko-connectors-zip-")
-        // #zip-reader
-        Archive
-          .zipReader(zipFile)
-          .mapAsyncUnordered(4) {
-            case (metadata, source) =>
-              val targetFile = target.resolve(metadata.name)
-              targetFile.toFile.getParentFile.mkdirs() // missing error handler
-              source.runWith(FileIO.toPath(targetFile))
-          }
-        // #zip-reader
+        try {
+          // #zip-reader
+          Archive
+            .zipReader(zipFile)
+            .mapAsyncUnordered(4) {
+              case (metadata, source) =>
+                val targetFile = target.resolve(metadata.name)
+                targetFile.toFile.getParentFile.mkdirs() // missing error 
handler
+                source.runWith(FileIO.toPath(targetFile))
+            }
+          // #zip-reader
+        } finally {
+          
Files.walk(target).sorted(java.util.Comparator.reverseOrder()).iterator().asScala.foreach(p
 =>
+            Files.delete(p))
+        }
       }
     }
   }
 
   private def getPathFromResources(fileName: String): Path =
-    Paths.get(getClass.getClassLoader.getResource(fileName).getPath)
+    Paths.get(getClass.getClassLoader.getResource(fileName).toURI)
 
   private def generateInputFiles(numberOfFiles: Int, lengthOfFile: Int): 
Map[String, ByteString] = {
     val r = new scala.util.Random(31)
diff --git a/file/src/test/scala/docs/scaladsl/FileTailSourceExtrasSpec.scala 
b/file/src/test/scala/docs/scaladsl/FileTailSourceExtrasSpec.scala
index a2a6878ce..bd858b434 100644
--- a/file/src/test/scala/docs/scaladsl/FileTailSourceExtrasSpec.scala
+++ b/file/src/test/scala/docs/scaladsl/FileTailSourceExtrasSpec.scala
@@ -43,11 +43,16 @@ class FileTailSourceExtrasSpec
     with ScalaFutures
     with LogCapturing {
 
-  private val fs = 
Jimfs.newFileSystem(Configuration.forCurrentPlatform.toBuilder.build)
+  import scala.jdk.CollectionConverters._
+  private val fs = Jimfs.newFileSystem(Configuration.forCurrentPlatform)
+  private val testFile = fs.getRootDirectories.asScala.head.resolve("file")
+
+  private val isWindows = System.getProperty("os.name", 
"").toLowerCase.contains("windows")
 
   "The FileTailSource" should assertAllStagesStopped {
     "demo stream shutdown when file deleted" in {
-      val path = fs.getPath("/file")
+      assume(!isWindows, "Jimfs WatchService does not fire events on Windows")
+      val path = testFile
       Files.write(path, "a\n".getBytes(UTF_8))
 
       // #shutdown-on-delete
@@ -81,7 +86,8 @@ class FileTailSourceExtrasSpec
     }
 
     "demo stream shutdown when with idle timeout" in {
-      val path = fs.getPath("/file")
+      assume(!isWindows, "Jimfs file I/O does not work reliably on Windows")
+      val path = testFile
       Files.write(path, "a\n".getBytes(UTF_8))
 
       // #shutdown-on-idle-timeout
@@ -107,4 +113,9 @@ class FileTailSourceExtrasSpec
     }
 
   }
+
+  override protected def afterAll(): Unit = {
+    fs.close()
+    super.afterAll()
+  }
 }
diff --git a/file/src/test/scala/docs/scaladsl/LogRotatorSinkSpec.scala 
b/file/src/test/scala/docs/scaladsl/LogRotatorSinkSpec.scala
index fb1676684..14489b7c5 100644
--- a/file/src/test/scala/docs/scaladsl/LogRotatorSinkSpec.scala
+++ b/file/src/test/scala/docs/scaladsl/LogRotatorSinkSpec.scala
@@ -106,6 +106,7 @@ class LogRotatorSinkSpec
     }
 
     "work for size-based rotation " in assertAllStagesStopped {
+      val createdFiles = Seq.newBuilder[Path]
       // #size
       import org.apache.pekko.stream.connectors.file.scaladsl.LogRotatorSink
 
@@ -115,6 +116,7 @@ class LogRotatorSinkSpec
         (element: ByteString) =>
           if (size + element.size > max) {
             val path = Files.createTempFile("out-", ".log")
+            createdFiles += path
             size = element.size
             Some(path)
           } else {
@@ -131,12 +133,16 @@ class LogRotatorSinkSpec
         .map(ByteString(_))
         .runWith(sizeRotatorSink)
 
-      fileSizeCompletion.futureValue shouldBe Done
+      try {
+        fileSizeCompletion.futureValue shouldBe Done
+      } finally {
+        createdFiles.result().foreach(f => Files.deleteIfExists(f))
+      }
     }
 
     "work for time-based rotation " in assertAllStagesStopped {
       // #time
-      val destinationDir = FileSystems.getDefault.getPath("/tmp")
+      val destinationDir = Files.createTempDirectory(fs.getPath("/"), 
"time-rotation")
       val formatter = 
DateTimeFormatter.ofPattern("'stream-'yyyy-MM-dd_HH'.log'")
 
       val timeBasedTriggerCreator: () => ByteString => Option[Path] = () => {
@@ -180,7 +186,7 @@ class LogRotatorSinkSpec
 
     "work for stream-based rotation " in assertAllStagesStopped {
       // #stream
-      val destinationDir = FileSystems.getDefault.getPath("/tmp")
+      val destinationDir = Files.createTempDirectory(fs.getPath("/"), 
"stream-rotation")
 
       val streamBasedTriggerCreator: () => ((String, String)) => Option[Path] 
= () => {
         var currentFilename: Option[String] = None
diff --git a/file/src/test/scala/docs/scaladsl/TarArchiveSpec.scala 
b/file/src/test/scala/docs/scaladsl/TarArchiveSpec.scala
index c40894052..60be20713 100644
--- a/file/src/test/scala/docs/scaladsl/TarArchiveSpec.scala
+++ b/file/src/test/scala/docs/scaladsl/TarArchiveSpec.scala
@@ -178,36 +178,40 @@ class TarArchiveSpec
         Source.future(oneFileArchive)
       val target = Files.createTempDirectory("pekko-connectors-tar-")
 
-      // #tar-reader
-      val tar =
-        bytesSource
-          .via(Archive.tarReader())
-          .mapAsync(1) {
-            case (metadata, source) =>
-              val targetFile = target.resolve(metadata.filePath)
-              if (metadata.isDirectory) {
-                Source
-                  .single(targetFile)
-                  .via(Directory.mkdirs())
-                  .runWith(Sink.ignore)
-              } else {
-                // create the target directory
-                Source
-                  .single(targetFile.getParent)
-                  .via(Directory.mkdirs())
-                  .runWith(Sink.ignore)
-                  .map { _ =>
-                    // stream the file contents to a local file
-                    source.runWith(FileIO.toPath(targetFile))
-                  }
-              }
-          }
-          .runWith(Sink.ignore)
-      // #tar-reader
-      tar.futureValue shouldBe Done
-      val file: File = target.resolve("dir/file1.txt").toFile
-      eventually {
-        file.exists() shouldBe true
+      try {
+        // #tar-reader
+        val tar =
+          bytesSource
+            .via(Archive.tarReader())
+            .mapAsync(1) {
+              case (metadata, source) =>
+                val targetFile = target.resolve(metadata.filePath)
+                if (metadata.isDirectory) {
+                  Source
+                    .single(targetFile)
+                    .via(Directory.mkdirs())
+                    .runWith(Sink.ignore)
+                } else {
+                  // create the target directory
+                  Source
+                    .single(targetFile.getParent)
+                    .via(Directory.mkdirs())
+                    .runWith(Sink.ignore)
+                    .map { _ =>
+                      // stream the file contents to a local file
+                      source.runWith(FileIO.toPath(targetFile))
+                    }
+                }
+            }
+            .runWith(Sink.ignore)
+        // #tar-reader
+        tar.futureValue shouldBe Done
+        val file: File = target.resolve("dir/file1.txt").toFile
+        eventually {
+          file.exists() shouldBe true
+        }
+      } finally {
+        
Files.walk(target).sorted(Comparator.reverseOrder()).iterator().asScala.foreach(p
 => Files.delete(p))
       }
     }
 
diff --git 
a/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/TarArchiveEntrySpec.scala
 
b/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/TarArchiveEntrySpec.scala
index 40f0b38b9..2e25e1ffe 100644
--- 
a/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/TarArchiveEntrySpec.scala
+++ 
b/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/TarArchiveEntrySpec.scala
@@ -22,6 +22,68 @@ import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 
 class TarArchiveEntrySpec extends AnyFlatSpec with Matchers {
+
+  "Path traversal validation" should "reject dot-dot in filename" in {
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveMetadata("../etc/passwd", 100L)
+    }
+  }
+
+  it should "reject dot-dot in prefix" in {
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveMetadata("../../etc", "passwd", 100L, Instant.now)
+    }
+  }
+
+  it should "reject absolute path in filename" in {
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveMetadata("/etc/passwd", 100L)
+    }
+  }
+
+  it should "reject dot-dot in middle of path" in {
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveMetadata("dir/../../../etc/passwd", 100L)
+    }
+  }
+
+  it should "reject dot-dot via parse" in {
+    // Build a tar header with a malicious filename
+    val malicious = TarArchiveMetadata("dir/file.txt", 100L)
+    val entry = new TarArchiveEntry(malicious)
+    val header = entry.headerBytes
+    // Corrupt the filename field to contain ../
+    val corrupted = header.toArray
+    val evilName = "../etc/crontab"
+    evilName.getBytes.zipWithIndex.foreach { case (b, i) => corrupted(i) = b }
+    corrupted(evilName.length) = 0 // null terminator
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveEntry.parse(ByteString(corrupted))
+    }
+  }
+
+  it should "accept normal relative paths" in {
+    val meta = TarArchiveMetadata("dir/subdir/file.txt", 100L)
+    meta.filePath shouldBe "dir/subdir/file.txt"
+  }
+
+  it should "accept simple filename" in {
+    val meta = TarArchiveMetadata("file.txt", 100L)
+    meta.filePath shouldBe "file.txt"
+  }
+
+  it should "reject backslashes in filename" in {
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveMetadata("\\etc\\passwd", 100L)
+    }
+  }
+
+  it should "reject backslashes in prefix" in {
+    an[IllegalArgumentException] should be thrownBy {
+      TarArchiveMetadata("\\etc", "passwd", 100L, Instant.now)
+    }
+  }
+
   "Metadata entries" should "be created and parsed back" in {
     val filePathPrefix = "dir1/dir2"
     val filename = "thefile.txt"
diff --git 
a/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/ZipArchiveMetadataSpec.scala
 
b/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/ZipArchiveMetadataSpec.scala
new file mode 100644
index 000000000..968a24614
--- /dev/null
+++ 
b/file/src/test/scala/org/apache/pekko/stream/connectors/file/impl/archive/ZipArchiveMetadataSpec.scala
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.stream.connectors.file.impl.archive
+
+import org.apache.pekko.stream.connectors.file.ZipArchiveMetadata
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class ZipArchiveMetadataSpec extends AnyFlatSpec with Matchers {
+
+  "ZipArchiveMetadata" should "reject dot-dot in name" in {
+    an[IllegalArgumentException] should be thrownBy {
+      ZipArchiveMetadata("../../etc/passwd")
+    }
+  }
+
+  it should "reject absolute path" in {
+    an[IllegalArgumentException] should be thrownBy {
+      ZipArchiveMetadata("/etc/passwd")
+    }
+  }
+
+  it should "reject dot-dot in middle of path" in {
+    an[IllegalArgumentException] should be thrownBy {
+      ZipArchiveMetadata("dir/../../../etc/crontab")
+    }
+  }
+
+  it should "accept normal relative paths" in {
+    val meta = ZipArchiveMetadata("dir/subdir/file.txt")
+    meta.name shouldBe "dir/subdir/file.txt"
+  }
+
+  it should "accept simple filename" in {
+    val meta = ZipArchiveMetadata("file.txt")
+    meta.name shouldBe "file.txt"
+  }
+
+  it should "reject backslashes in name" in {
+    an[IllegalArgumentException] should be thrownBy {
+      ZipArchiveMetadata("\\etc\\passwd")
+    }
+  }
+
+  it should "reject backslashes in middle of path" in {
+    an[IllegalArgumentException] should be thrownBy {
+      ZipArchiveMetadata("dir\\..\\..\\etc\\passwd")
+    }
+  }
+}
diff --git 
a/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperations.scala
 
b/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperations.scala
index 5b7a56bc8..b3af9eddb 100644
--- 
a/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperations.scala
+++ 
b/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperations.scala
@@ -76,21 +76,26 @@ private[ftp] trait CommonFtpOperations {
     retrieveFileInputStream(name, handler, 0L)
 
   def retrieveFileInputStream(name: String, handler: Handler, offset: Long): 
Try[InputStream] = Try {
+    CommonFtpOperations.validatePath(name, "name")
     handler.setRestartOffset(offset)
     val is = handler.retrieveFileStream(name)
     if (is != null) is else throw new IOException(s"$name: No such file or 
directory")
   }
 
   def storeFileOutputStream(name: String, handler: Handler, append: Boolean): 
Try[OutputStream] = Try {
+    CommonFtpOperations.validatePath(name, "name")
     val os = if (append) handler.appendFileStream(name) else 
handler.storeFileStream(name)
     if (os != null) os else throw new IOException(s"Could not write to $name")
   }
 
   def move(fromPath: String, destinationPath: String, handler: Handler): Unit 
= {
+    CommonFtpOperations.validatePath(fromPath, "fromPath")
+    CommonFtpOperations.validatePath(destinationPath, "destinationPath")
     if (!handler.rename(fromPath, destinationPath)) throw new 
IOException(s"Could not move $fromPath")
   }
 
   def remove(path: String, handler: Handler): Unit = {
+    CommonFtpOperations.validatePath(path, "path")
     if (!handler.deleteFile(path)) throw new IOException(s"Could not delete 
$path")
   }
 
@@ -108,10 +113,57 @@ private[ftp] trait CommonFtpOperations {
 }
 
 private[ftp] object CommonFtpOperations {
-  def concatPath(path: String, name: String): String =
-    if (path.endsWith("/")) {
-      path ++ name
+
+  /**
+   * Normalize a path to use `/` separators. FTP uses `/` by protocol;
+   * normalizing early ensures all downstream checks only need to handle `/`.
+   */
+  private def normalizeSeparators(path: String): String = path.replace('\\', 
'/')
+
+  /**
+   * Validate that a path does not contain traversal sequences (`..`).
+   * Rejects null values and paths containing `..` as a path segment.
+   * Accepts both `/` and `\` separators; backslashes are normalized to `/` 
before checking.
+   *
+   * @param path      the path to validate
+   * @param fieldName the name of the field for error messages
+   * @throws IllegalArgumentException if the path contains traversal sequences
+   */
+  def validatePath(path: String, fieldName: String): Unit = {
+    require(path != null, s"$fieldName must not be null")
+    val normalized = normalizeSeparators(path)
+    val segments = normalized.split('/')
+    require(!segments.contains(".."), s"$fieldName must not contain path 
traversal sequences: '$path'")
+  }
+
+  def concatPath(path: String, name: String): String = {
+    validatePath(name, "name")
+    val normName = normalizeSeparators(name)
+    require(!normName.startsWith("/"), s"name must not be an absolute path: 
'$normName'")
+
+    require(path != null, "path must not be null")
+    val normPath = normalizeSeparators(path)
+    val result = if (normPath.endsWith("/")) {
+      normPath ++ normName
     } else {
-      s"$path/$name"
+      s"$normPath/$normName"
     }
+
+    // Pure string segment walk — no platform-dependent normalization.
+    // `..` and absolute names are already rejected upstream; this collapses
+    // `.` segments and empty segments (double slashes) so the prefix check
+    // cannot be fooled by cosmetic differences.
+    def collapseSegments(p: String): String = {
+      val parts = p.split('/').filter(s => s.nonEmpty && s != ".")
+      if (parts.isEmpty) "" else parts.mkString("/")
+    }
+    val collapsed = collapseSegments(result)
+    val collapsedBase = collapseSegments(normPath)
+    // collapsedBase is empty when the base path is "/" (root) — everything is 
under root
+    require(
+      collapsedBase.isEmpty || collapsed == collapsedBase || 
collapsed.startsWith(collapsedBase + "/"),
+      s"concatPath result '$result' escapes base path '$normPath'")
+
+    result
+  }
 }
diff --git 
a/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/SftpOperations.scala
 
b/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/SftpOperations.scala
index 05ee5f538..9ea4d32ab 100644
--- 
a/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/SftpOperations.scala
+++ 
b/ftp/src/main/scala/org/apache/pekko/stream/connectors/ftp/impl/SftpOperations.scala
@@ -138,6 +138,7 @@ private[ftp] trait SftpOperations { self: 
FtpLike[SSHClient, SftpSettings] =>
       offset: Long,
       maxUnconfirmedReads: Int): Try[InputStream] =
     Try {
+      CommonFtpOperations.validatePath(name, "name")
       val remoteFile = handler.open(name, java.util.EnumSet.of(OpenMode.READ))
       val is = maxUnconfirmedReads match {
         case m if m > 1 =>
@@ -169,6 +170,7 @@ private[ftp] trait SftpOperations { self: 
FtpLike[SSHClient, SftpSettings] =>
 
   def storeFileOutputStream(name: String, handler: Handler, append: Boolean): 
Try[OutputStream] =
     Try {
+      CommonFtpOperations.validatePath(name, "name")
       import OpenMode._
       val openModes =
         if (append) java.util.EnumSet.of(WRITE, CREAT, APPEND)
@@ -208,9 +210,14 @@ private[ftp] trait SftpOperations { self: 
FtpLike[SSHClient, SftpSettings] =>
     }
   }
 
-  def move(fromPath: String, destinationPath: String, handler: Handler): Unit =
+  def move(fromPath: String, destinationPath: String, handler: Handler): Unit 
= {
+    CommonFtpOperations.validatePath(fromPath, "fromPath")
+    CommonFtpOperations.validatePath(destinationPath, "destinationPath")
     handler.rename(fromPath, destinationPath)
+  }
 
-  def remove(path: String, handler: Handler): Unit =
+  def remove(path: String, handler: Handler): Unit = {
+    CommonFtpOperations.validatePath(path, "path")
     handler.rm(path)
+  }
 }
diff --git 
a/ftp/src/test/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperationsSpec.scala
 
b/ftp/src/test/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperationsSpec.scala
new file mode 100644
index 000000000..f9b7bcbd2
--- /dev/null
+++ 
b/ftp/src/test/scala/org/apache/pekko/stream/connectors/ftp/impl/CommonFtpOperationsSpec.scala
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.stream.connectors.ftp.impl
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class CommonFtpOperationsSpec extends AnyFlatSpec with Matchers {
+
+  "CommonFtpOperations.validatePath" should "accept normal relative path" in {
+    CommonFtpOperations.validatePath("dir/file.txt", "path") // no exception
+  }
+
+  it should "accept simple filename" in {
+    CommonFtpOperations.validatePath("file.txt", "path") // no exception
+  }
+
+  it should "reject null" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.validatePath(null, "path")
+    }
+  }
+
+  it should "reject dot-dot at start" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.validatePath("../etc/passwd", "path")
+    }
+  }
+
+  it should "reject dot-dot in middle" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.validatePath("dir/../../etc/passwd", "path")
+    }
+  }
+
+  it should "reject dot-dot at end" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.validatePath("dir/..", "path")
+    }
+  }
+
+  "CommonFtpOperations.concatPath" should "concatenate simple paths" in {
+    CommonFtpOperations.concatPath("/base", "file.txt") shouldBe 
"/base/file.txt"
+  }
+
+  it should "handle trailing slash on base path" in {
+    CommonFtpOperations.concatPath("/base/", "file.txt") shouldBe 
"/base/file.txt"
+  }
+
+  it should "handle nested names" in {
+    CommonFtpOperations.concatPath("/base", "subdir/file.txt") shouldBe 
"/base/subdir/file.txt"
+  }
+
+  it should "reject dot-dot in name" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.concatPath("/base", "../etc/passwd")
+    }
+  }
+
+  it should "reject dot-dot in nested name" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.concatPath("/base", "subdir/../../etc/passwd")
+    }
+  }
+
+  it should "reject absolute name" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.concatPath("/base", "/etc/passwd")
+    }
+  }
+
+  it should "reject null name" in {
+    an[IllegalArgumentException] should be thrownBy {
+      CommonFtpOperations.concatPath("/base", null)
+    }
+  }
+
+  it should "handle dot segments without platform normalization" in {
+    CommonFtpOperations.concatPath("/base", "./file.txt") shouldBe 
"/base/./file.txt"
+  }
+
+  it should "allow names under root path" in {
+    CommonFtpOperations.concatPath("/", "sample_dir") shouldBe "/sample_dir"
+  }
+}


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

Reply via email to