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

oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 177fb1fdff90 CAMEL-24487: remote-file consumers - contain remote 
operations within the configured directory (#25730)
177fb1fdff90 is described below

commit 177fb1fdff90632ebfd27772a625b720c1cab3e7
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Aug 25 23:33:31 2026 +0200

    CAMEL-24487: remote-file consumers - contain remote operations within the 
configured directory (#25730)
    
    The remote-file consumers build the absolute remote path by concatenating 
the
    configured directory with the file name reported in the server directory 
listing,
    and then use that result as the operand for the subsequent retrieve, delete 
and
    rename operations. No lexical normalization or containment check was 
applied at any
    point between the listing and those operations, so a listing entry that is 
not a
    single path segment could resolve outside the configured directory.
    
    This is inconsistent with the file producer and with the localWorkDirectory 
download
    path, which both contain the resolved path via FileUtil.compactPath plus a
    path-boundary check when jailStartingDirectory is enabled (CAMEL-23765, 
CAMEL-23868).
    
    Adds a separator-aware GenericFileHelper.isWithinDirectory overload, since 
remote
    paths always use '/' regardless of the platform Camel runs on, and a
    GenericFileConsumer.isWithinStartingDirectory strategy that the consumer 
consults in
    isValidFile when jailStartingDirectory is enabled. The base implementation 
is a no-op
    for local directory listings, whose names are always single path segments;
    RemoteFileConsumer (camel-ftp, camel-ftps, camel-sftp, camel-mina-sftp,
    camel-azure-files) and SmbConsumer (camel-smb) override it to compact the 
resolved
    path and check it still resolves inside the directory being polled. A file 
resolving
    outside is skipped with a warning, so the rest of the listing still 
processes.
    
    The check runs after the existing match filters, so the "." and ".." 
listing entries
    that SFTP servers return are already excluded by the hidden-file rule and 
do not log.
    
    Adds GenericFileHelper tests for the separator-aware overload,
    RemoteFileConsumerStartingDirectoryJailTest covering FTP and SFTP 
endpoints, and a
    4.23 upgrade-guide note.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../camel/component/file/GenericFileConsumer.java  | 23 +++++++
 .../camel/component/file/GenericFileHelper.java    | 24 ++++++-
 .../component/file/GenericFileHelperTest.java      | 27 ++++++++
 .../component/file/remote/RemoteFileConsumer.java  | 23 +++++++
 ...emoteFileConsumerStartingDirectoryJailTest.java | 77 ++++++++++++++++++++++
 .../apache/camel/component/smb/SmbConsumer.java    | 21 ++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 17 +++++
 7 files changed, 210 insertions(+), 2 deletions(-)

diff --git 
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
 
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
index 76a775a91b74..fe72a23124b1 100644
--- 
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
+++ 
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileConsumer.java
@@ -630,6 +630,13 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
             return false;
         }
 
+        // the resolved path must stay within the starting directory, as the 
name it was built from is
+        // reported by the remote server and is not necessarily a single path 
segment
+        if (endpoint.isJailStartingDirectory() && 
!isWithinStartingDirectory(absoluteFilePath)) {
+            LOG.warn("Skipping file as it resolves outside the starting 
directory: {}", absoluteFilePath);
+            return false;
+        }
+
         // directory is always valid
         if (isDirectory) {
             return true;
@@ -679,6 +686,22 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
         return answer;
     }
 
+    /**
+     * Strategy to determine whether the resolved path of a listed file stays 
within the configured starting directory.
+     * <p/>
+     * Consumers that build the path from a name supplied by a remote server 
must override this, as such a name is not
+     * guaranteed to be a single path segment and can otherwise navigate 
outside the directory being polled. The check
+     * is only consulted when {@link 
GenericFileEndpoint#isJailStartingDirectory()} is enabled.
+     *
+     * @param  absoluteFilePath the resolved absolute path of the listed file
+     * @return                  {@code true} if the path stays within the 
starting directory
+     */
+    protected boolean isWithinStartingDirectory(String absoluteFilePath) {
+        // names obtained from a local directory listing are always single 
path segments, so there is no
+        // boundary to enforce here
+        return true;
+    }
+
     /**
      * Strategy to perform hidden file matching based on endpoint 
configuration.
      * <p/>
diff --git 
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileHelper.java
 
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileHelper.java
index 7c73d148ac67..ff3957e296e5 100644
--- 
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileHelper.java
+++ 
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileHelper.java
@@ -58,8 +58,28 @@ public final class GenericFileHelper {
      * @param  compactTarget the compacted target path (see {@link 
FileUtil#compactPath(String)})
      * @param  compactDir    the compacted directory the target must stay 
within
      * @return               {@code true} if the target is the directory 
itself or a path inside it
+     * @see                  #isWithinDirectory(String, String, char)
      */
     public static boolean isWithinDirectory(String compactTarget, String 
compactDir) {
+        return isWithinDirectory(compactTarget, compactDir, 
File.separatorChar);
+    }
+
+    /**
+     * Determines whether a compacted target path is contained within a 
compacted directory path, using the given path
+     * separator. Remote file paths always use {@code /} regardless of the 
platform Camel runs on, so remote callers
+     * must pass {@code '/'} rather than relying on {@link File#separatorChar}.
+     *
+     * @param  compactTarget the compacted target path (see {@link 
FileUtil#compactPath(String, char)})
+     * @param  compactDir    the compacted directory the target must stay 
within
+     * @param  separator     the path separator both paths are expressed with
+     * @return               {@code true} if the target is the directory 
itself or a path inside it
+     */
+    public static boolean isWithinDirectory(String compactTarget, String 
compactDir, char separator) {
+        // a target that still resolves upwards after compaction escapes any 
root, even when no boundary is
+        // configured, so it is never contained
+        if (compactTarget.equals("..") || compactTarget.startsWith(".." + 
separator)) {
+            return false;
+        }
         if (compactDir.isEmpty()) {
             // no directory boundary configured
             return true;
@@ -67,10 +87,10 @@ public final class GenericFileHelper {
         // drop a trailing separator (if any) so the boundary comparison is 
exact, regardless of whether the
         // directory path was supplied with or without one
         String dir = compactDir;
-        if (dir.charAt(dir.length() - 1) == File.separatorChar) {
+        if (dir.charAt(dir.length() - 1) == separator) {
             dir = dir.substring(0, dir.length() - 1);
         }
-        return compactTarget.equals(dir) || compactTarget.startsWith(dir + 
File.separator);
+        return compactTarget.equals(dir) || compactTarget.startsWith(dir + 
separator);
     }
 
     public static String asExclusiveReadLockKey(GenericFile file, String key) {
diff --git 
a/components/camel-file/src/test/java/org/apache/camel/component/file/GenericFileHelperTest.java
 
b/components/camel-file/src/test/java/org/apache/camel/component/file/GenericFileHelperTest.java
index e52a63ffa497..2b6c29d10b79 100644
--- 
a/components/camel-file/src/test/java/org/apache/camel/component/file/GenericFileHelperTest.java
+++ 
b/components/camel-file/src/test/java/org/apache/camel/component/file/GenericFileHelperTest.java
@@ -71,4 +71,31 @@ public class GenericFileHelperTest {
         // an empty directory imposes no boundary
         assertTrue(GenericFileHelper.isWithinDirectory("anything.txt", ""));
     }
+
+    @Test
+    public void isWithinDirectoryUsesTheGivenSeparator() {
+        // remote paths always use '/', regardless of the platform Camel runs 
on
+        assertTrue(GenericFileHelper.isWithinDirectory("poll/file.txt", 
"poll", '/'));
+        assertTrue(GenericFileHelper.isWithinDirectory("poll/sub/file.txt", 
"poll", '/'));
+        assertTrue(GenericFileHelper.isWithinDirectory("poll", "poll", '/'));
+        assertTrue(GenericFileHelper.isWithinDirectory("/poll/file.txt", 
"/poll", '/'));
+
+        // a trailing separator on the directory is tolerated
+        assertTrue(GenericFileHelper.isWithinDirectory("poll/file.txt", 
"poll/", '/'));
+
+        // a sibling whose name merely extends the directory name is NOT 
contained
+        assertFalse(GenericFileHelper.isWithinDirectory("pollute/file.txt", 
"poll", '/'));
+    }
+
+    @Test
+    public void isWithinDirectoryRejectsPathsResolvingOutsideTheDirectory() {
+        // the compacted result of a listing name that navigates above the 
polled directory
+        assertFalse(GenericFileHelper.isWithinDirectory("../secret.txt", 
"poll", '/'));
+        assertFalse(GenericFileHelper.isWithinDirectory("../../etc/shadow", 
"poll", '/'));
+        assertFalse(GenericFileHelper.isWithinDirectory("/secret.txt", 
"/poll", '/'));
+
+        // a target that still resolves upwards escapes even when no directory 
boundary is configured
+        assertFalse(GenericFileHelper.isWithinDirectory("..", "", '/'));
+        assertFalse(GenericFileHelper.isWithinDirectory("../secret.txt", "", 
'/'));
+    }
 }
diff --git 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/RemoteFileConsumer.java
 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/RemoteFileConsumer.java
index 967e00c0e227..ad780f979043 100644
--- 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/RemoteFileConsumer.java
+++ 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/RemoteFileConsumer.java
@@ -24,9 +24,11 @@ import org.apache.camel.Ordered;
 import org.apache.camel.Processor;
 import org.apache.camel.component.file.GenericFile;
 import org.apache.camel.component.file.GenericFileConsumer;
+import org.apache.camel.component.file.GenericFileHelper;
 import org.apache.camel.component.file.GenericFileOperationFailedException;
 import org.apache.camel.component.file.GenericFileProcessStrategy;
 import org.apache.camel.support.SynchronizationAdapter;
+import org.apache.camel.util.FileUtil;
 import org.apache.camel.util.ObjectHelper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -38,6 +40,11 @@ public abstract class RemoteFileConsumer<T> extends 
GenericFileConsumer<T> {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(RemoteFileConsumer.class);
 
+    /**
+     * Remote paths always use {@code /}, regardless of the platform Camel 
runs on.
+     */
+    private static final char REMOTE_PATH_SEPARATOR = '/';
+
     protected transient boolean loggedIn;
     protected transient boolean loggedInWarning;
     protected transient boolean autoCreatedDone;
@@ -60,6 +67,22 @@ public abstract class RemoteFileConsumer<T> extends 
GenericFileConsumer<T> {
         return (RemoteFileOperations<T>) operations;
     }
 
+    /**
+     * The file name comes from the directory listing returned by the remote 
server and is not guaranteed to be a single
+     * path segment, so the path resolved from it is compacted and checked to 
still be inside the directory being polled
+     * before the file is accepted for retrieval, deletion or renaming.
+     */
+    @Override
+    protected boolean isWithinStartingDirectory(String absoluteFilePath) {
+        if (absoluteFilePath == null) {
+            return false;
+        }
+        String directory = getEndpoint().getConfiguration().getDirectory();
+        String compactDir = directory != null ? 
FileUtil.compactPath(directory, REMOTE_PATH_SEPARATOR) : "";
+        return GenericFileHelper.isWithinDirectory(
+                FileUtil.compactPath(absoluteFilePath, REMOTE_PATH_SEPARATOR), 
compactDir, REMOTE_PATH_SEPARATOR);
+    }
+
     @Override
     protected Exchange createExchange(GenericFile<T> file) {
         Exchange answer = createExchange(true);
diff --git 
a/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/RemoteFileConsumerStartingDirectoryJailTest.java
 
b/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/RemoteFileConsumerStartingDirectoryJailTest.java
new file mode 100644
index 000000000000..c4a8008a597c
--- /dev/null
+++ 
b/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/RemoteFileConsumerStartingDirectoryJailTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.camel.component.file.remote;
+
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The file name used to build the path of a polled file comes from the 
directory listing returned by the remote server,
+ * so it is not guaranteed to be a single path segment. Verifies the resolved 
path is kept inside the directory being
+ * polled before it is used as the operand for retrieving, deleting or 
renaming.
+ */
+class RemoteFileConsumerStartingDirectoryJailTest extends CamelTestSupport {
+
+    private RemoteFileConsumer<?> consumer(String uri) throws Exception {
+        RemoteFileEndpoint<?> endpoint = context.getEndpoint(uri, 
RemoteFileEndpoint.class);
+        return (RemoteFileConsumer<?>) endpoint.createConsumer(exchange -> {
+        });
+    }
+
+    @Test
+    void shouldAcceptPathsWithinTheStartingDirectory() throws Exception {
+        RemoteFileConsumer<?> consumer = consumer("ftp://hostname/poll";);
+
+        assertTrue(consumer.isWithinStartingDirectory("poll/file.txt"));
+        assertTrue(consumer.isWithinStartingDirectory("poll/sub/file.txt"));
+        // a ../ that still resolves back inside the polled directory is 
legitimate
+        assertTrue(consumer.isWithinStartingDirectory("poll/sub/../file.txt"));
+    }
+
+    @Test
+    void shouldRejectPathsEscapingTheStartingDirectory() throws Exception {
+        RemoteFileConsumer<?> consumer = consumer("ftp://hostname/poll";);
+
+        
assertFalse(consumer.isWithinStartingDirectory("poll/a/../../../secret.txt"));
+        assertFalse(consumer.isWithinStartingDirectory("poll/../secret.txt"));
+        
assertFalse(consumer.isWithinStartingDirectory("poll/../../etc/shadow"));
+        // a sibling directory whose name merely extends the polled directory 
name is not contained
+        
assertFalse(consumer.isWithinStartingDirectory("poll/../pollute/secret.txt"));
+        assertFalse(consumer.isWithinStartingDirectory(null));
+    }
+
+    @Test
+    void shouldRejectPathsEscapingTheStartingDirectoryOverSftp() throws 
Exception {
+        RemoteFileConsumer<?> consumer = consumer("sftp://hostname/poll";);
+
+        assertTrue(consumer.isWithinStartingDirectory("poll/file.txt"));
+        
assertFalse(consumer.isWithinStartingDirectory("poll/a/../../../secret.txt"));
+        assertFalse(consumer.isWithinStartingDirectory("poll/../secret.txt"));
+    }
+
+    @Test
+    void shouldRejectUpwardsPathsWhenPollingTheSessionRoot() throws Exception {
+        RemoteFileConsumer<?> consumer = consumer("ftp://hostname";);
+
+        assertTrue(consumer.isWithinStartingDirectory("file.txt"));
+        // no directory is configured, but navigating above the session root 
still escapes
+        assertFalse(consumer.isWithinStartingDirectory("../secret.txt"));
+    }
+}
diff --git 
a/components/camel-smb/src/main/java/org/apache/camel/component/smb/SmbConsumer.java
 
b/components/camel-smb/src/main/java/org/apache/camel/component/smb/SmbConsumer.java
index 4ffa70057271..c529b5502783 100644
--- 
a/components/camel-smb/src/main/java/org/apache/camel/component/smb/SmbConsumer.java
+++ 
b/components/camel-smb/src/main/java/org/apache/camel/component/smb/SmbConsumer.java
@@ -32,6 +32,7 @@ import org.apache.camel.Processor;
 import org.apache.camel.component.file.GenericFile;
 import org.apache.camel.component.file.GenericFileConsumer;
 import org.apache.camel.component.file.GenericFileEndpoint;
+import org.apache.camel.component.file.GenericFileHelper;
 import org.apache.camel.component.file.GenericFileOperationFailedException;
 import org.apache.camel.component.file.GenericFileOperations;
 import org.apache.camel.component.file.GenericFileProcessStrategy;
@@ -47,6 +48,11 @@ public class SmbConsumer extends 
GenericFileConsumer<FileIdBothDirectoryInformat
 
     private static final Logger LOG = 
LoggerFactory.getLogger(SmbConsumer.class);
 
+    /**
+     * Remote paths always use {@code /}, regardless of the platform Camel 
runs on.
+     */
+    private static final char REMOTE_PATH_SEPARATOR = '/';
+
     private final SmbEndpoint endpoint;
     private final SmbConfiguration configuration;
     private final String endpointPath;
@@ -71,6 +77,21 @@ public class SmbConsumer extends 
GenericFileConsumer<FileIdBothDirectoryInformat
         return (GenericFileEndpoint<FileIdBothDirectoryInformation>) 
super.getEndpoint();
     }
 
+    /**
+     * The file name comes from the directory listing returned by the remote 
share and is not guaranteed to be a single
+     * path segment, so the path resolved from it is compacted and checked to 
still be inside the directory being polled
+     * before the file is accepted for retrieval, deletion or renaming.
+     */
+    @Override
+    protected boolean isWithinStartingDirectory(String absoluteFilePath) {
+        if (absoluteFilePath == null) {
+            return false;
+        }
+        return GenericFileHelper.isWithinDirectory(
+                FileUtil.compactPath(absoluteFilePath, REMOTE_PATH_SEPARATOR),
+                FileUtil.compactPath(endpointPath, REMOTE_PATH_SEPARATOR), 
REMOTE_PATH_SEPARATOR);
+    }
+
     @Override
     protected boolean pollDirectory(
             Exchange dynamic, String path, 
List<GenericFile<FileIdBothDirectoryInformation>> fileList, int depth) {
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 88c74de6f50b..3c88f2fbad71 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -248,3 +248,20 @@ Camel then no longer controls how the payload is read.
 
 The legacy `sse` `transportType` has been removed. It follows the support 
removal in 
 
https://github.com/langchain4j/langchain4j/commit/7f4e99fd637137ab9d3bf116eb62047ac757a818[langchain4j-core
 1.19.0].
+
+=== camel-ftp, camel-sftp, camel-ftps, camel-mina-sftp, camel-azure-files, 
camel-smb
+
+The remote-file consumers now ensure the path resolved for a polled file stays 
within the directory being
+polled. The file name that path is built from is reported by the remote server 
in its directory listing and is
+not guaranteed to be a single path segment, so a listing entry containing 
`../` sequences could previously
+resolve to a path outside the configured directory and be used as the operand 
for retrieving, deleting or
+renaming a file.
+
+The containment check honours the existing `jailStartingDirectory` option 
(default `true`), consistent with the
+file producer and with the `localWorkDirectory` download path; set 
`jailStartingDirectory=false` to disable it.
+A file that resolves outside the configured directory is now skipped, and a 
warning is logged.
+
+Ordinary listings are unaffected, as a listed name is normally a single path 
segment, and a `../` that still
+resolves back inside the polled directory remains accepted. Two configurations 
can newly see files skipped: a
+server that reports names navigating above the polled directory, and a 
`fileName` expression (used when
+`useList=false`) that navigates above it. Set `jailStartingDirectory=false` if 
such a path is intended.

Reply via email to