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 5ab8a591b546 CAMEL-25886: camel-ftp/mina-sftp - resolve listed 
symbolic links for jailStartingDirectory (#28214)
5ab8a591b546 is described below

commit 5ab8a591b5465b95a2239e48923cbfc58db701b1
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 29 15:14:04 2026 +0100

    CAMEL-25886: camel-ftp/mina-sftp - resolve listed symbolic links for 
jailStartingDirectory (#28214)
    
    With jailStartingDirectory enabled (the default), the sftp and mina-sftp
    consumers now resolve a listed file that the server reports as a symbolic
    link, one path segment at a time with lstat and readlink, and skip it when
    its target is outside the starting directory or cannot be resolved, as the
    file consumer does since CAMEL-25412. Links resolving inside the starting
    directory, and a starting directory that is itself a link, work as before.
    jailStartingDirectory=false keeps following every link.
    
    Signed-off-by: Andrea Cosentino <[email protected]>
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../camel/catalog/docs/mina-sftp-component.adoc    |  12 +-
 .../apache/camel/catalog/docs/sftp-component.adoc  |  15 ++
 .../camel/component/file/GenericFileConsumer.java  |  34 ++-
 .../file/remote/AbstractSftpConsumer.java          | 138 ++++++++++
 .../component/file/remote/SftpRemoteFile.java      |   7 +
 .../file/remote/SftpSymbolicLinkOperations.java    |  53 ++++
 .../camel-ftp/src/main/docs/sftp-component.adoc    |  15 ++
 .../component/file/remote/SftpOperations.java      |  82 +++++-
 .../file/remote/SftpRemoteFileJCraft.java          |   5 +
 ...emoteFileConsumerStartingDirectoryJailTest.java | 255 +++++++++++++++++-
 ...SftpConsumerJailStartingDirectorySymlinkIT.java | 291 +++++++++++++++++++++
 .../src/main/docs/mina-sftp-component.adoc         |  12 +-
 .../file/remote/mina/MinaSftpOperations.java       |  55 +++-
 .../file/remote/mina/MinaSftpRemoteFile.java       |   5 +
 .../mina/MinaSftpOperationsDisconnectTest.java     |  38 +++
 ...SftpConsumerJailStartingDirectorySymlinkIT.java | 291 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   9 +
 17 files changed, 1309 insertions(+), 8 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
index 8f282d231c19..d741b118a0c3 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/mina-sftp-component.adoc
@@ -641,7 +641,17 @@ IMPORTANT: The `chmod` and `chmodDirectory` options only 
work on POSIX-compatibl
 
 == Symbolic Links
 
-The consumer follows symbolic links to their target files. The producer writes 
to symlink targets.
+The consumer follows symbolic links to their target files, within the starting 
directory unless `jailStartingDirectory=false` (see below). The producer writes 
to symlink targets.
+
+With `jailStartingDirectory` enabled (the default), the consumer resolves a 
listed file that the server reports as a
+symbolic link before it accepts the file, one path segment at a time with the 
`lstat` and `readlink` requests. A link
+whose target is outside the starting directory, or cannot be resolved (such as 
a dangling link), is skipped and logged
+at WARN the first time. A link whose target contains a backslash (`\`) is 
skipped too, as a server may take it as a
+path separator or as part of a name. Links that resolve inside the starting 
directory, and a starting directory that is
+itself a link, work as usual. Set `jailStartingDirectory=false` to follow 
links to other directories, such as the links
+in `data` below when `data` is the starting directory. Only the files that the 
listing reports as links are resolved (a
+server that lists the attributes of the target instead does not show them as 
links), and the check and the download
+that follows are not atomic, so the permissions and link settings of the SFTP 
server still matter.
 
 NOTE: **Absolute symlinks in chroot environments**: If the SFTP server uses a 
chroot jail, absolute symlinks may not resolve correctly. Use **relative 
symlinks** for maximum compatibility.
 
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
index 7522a5d1d490..f893b297d3c3 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/sftp-component.adoc
@@ -311,6 +311,21 @@ ssh -vvv <server_address>
 
 As of Camel 3.18.1, these values can also be set on SFTP endpoints by setting 
the corresponding URI parameters.
 
+=== Symbolic links and the starting directory
+
+With `jailStartingDirectory` enabled (the default), the consumer resolves a 
listed file that the server reports as a
+symbolic link before it accepts the file. A link whose target is outside the 
starting directory, or cannot be resolved
+(such as a dangling link), is skipped and logged at WARN the first time. A 
link whose target contains a backslash (`\`)
+is skipped too, as a server may take it as a path separator or as part of a 
name. Links that resolve inside the
+starting directory, and a starting directory that is itself a link, work as 
usual. Set `jailStartingDirectory=false` to
+follow links to other directories.
+
+The link is resolved on the server one path segment at a time, with the 
`lstat` and `readlink` requests, so each listed
+link takes a few more requests per poll; other files take none. Only the files 
that the listing reports as links are
+resolved: OpenSSH and Apache MINA SSHD list a link as a link, but a server 
that lists the attributes of the target
+instead does not show it as a link, and with `useList=false` there is no 
listing. The check and the download that
+follows are not atomic, so the permissions and link settings of the SFTP 
server still matter.
+
 == More Information
 
 For more information, you can look at the xref:ftp-component.adoc[FTP 
component].
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 8f93f07a20a6..1ddfa6e0113d 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
@@ -814,12 +814,15 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
         // 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, and a local file
         // can be a symbolic link
-        if (endpoint.isJailStartingDirectory() && 
!isWithinStartingDirectory(absoluteFilePath)) {
+        if (endpoint.isJailStartingDirectory() && 
!isWithinStartingDirectory(file, absoluteFilePath)) {
+            String message = isUnresolvableLink(absoluteFilePath)
+                    ? "Skipping file as symbolic link cannot be resolved: {}"
+                    : "Skipping file as it resolves outside the starting 
directory: {}";
             // such a file usually stays where it is and is listed again on 
every poll, so only warn the first time
             if (skippedOutsideStartingDirectory.put(absoluteFilePath, 
Boolean.TRUE) == null) {
-                LOG.warn("Skipping file as it resolves outside the starting 
directory: {}", absoluteFilePath);
+                LOG.warn(message, absoluteFilePath);
             } else {
-                LOG.debug("Skipping file as it resolves outside the starting 
directory: {}", absoluteFilePath);
+                LOG.debug(message, absoluteFilePath);
             }
             return false;
         }
@@ -889,6 +892,31 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
         return true;
     }
 
+    /**
+     * Strategy to determine whether a listed file stays within the configured 
starting directory.
+     * <p/>
+     * Checks the resolved path with {@link 
#isWithinStartingDirectory(String)} by default. Consumers whose listing
+     * tells more about a file than its path, such as whether it is a symbolic 
link, override this. The check is only
+     * consulted when {@link GenericFileEndpoint#isJailStartingDirectory()} is 
enabled.
+     *
+     * @param  file             the listed file
+     * @param  absoluteFilePath the resolved absolute path of the listed file
+     * @return                  {@code true} if the file stays within the 
starting directory
+     */
+    protected boolean isWithinStartingDirectory(Supplier<GenericFile<T>> file, 
String absoluteFilePath) {
+        return isWithinStartingDirectory(absoluteFilePath);
+    }
+
+    /**
+     * Strategy to determine whether a skipped file could not be resolved 
(such as a dangling symbolic link).
+     *
+     * @param  absoluteFilePath the resolved absolute path of the listed file
+     * @return                  {@code true} if the file could not be resolved 
rather than resolving outside
+     */
+    protected boolean isUnresolvableLink(String absoluteFilePath) {
+        return false;
+    }
+
     /**
      * Strategy to perform hidden file matching based on endpoint 
configuration.
      * <p/>
diff --git 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/AbstractSftpConsumer.java
 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/AbstractSftpConsumer.java
index f2ee39f1071d..21593e23a3d7 100644
--- 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/AbstractSftpConsumer.java
+++ 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/AbstractSftpConsumer.java
@@ -16,9 +16,13 @@
  */
 package org.apache.camel.component.file.remote;
 
+import java.util.ArrayDeque;
 import java.util.Arrays;
 import java.util.Comparator;
+import java.util.Deque;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Supplier;
 
 import org.apache.camel.Exchange;
@@ -41,8 +45,18 @@ public abstract class AbstractSftpConsumer extends 
RemoteFileConsumer<SftpRemote
 
     private static final Logger LOG = 
LoggerFactory.getLogger(AbstractSftpConsumer.class);
 
+    /**
+     * The most symbolic links followed to resolve a path, as a longer chain 
is most likely a loop
+     */
+    private static final int MAX_SYMBOLIC_LINKS = 32;
+
     protected String endpointPath;
 
+    // the starting directory with its symbolic links resolved on the server, 
computed once per poll
+    private volatile String resolvedStartingDirectory;
+
+    private final Set<String> unresolvableLinks = 
ConcurrentHashMap.newKeySet();
+
     protected AbstractSftpConsumer(RemoteFileEndpoint<SftpRemoteFile> 
endpoint, Processor processor,
                                    RemoteFileOperations<SftpRemoteFile> 
operations,
                                    GenericFileProcessStrategy<SftpRemoteFile> 
processStrategy) {
@@ -78,8 +92,18 @@ public abstract class AbstractSftpConsumer extends 
RemoteFileConsumer<SftpRemote
         }
     }
 
+    @Override
+    protected void doStop() throws Exception {
+        unresolvableLinks.clear();
+        super.doStop();
+    }
+
     @Override
     protected boolean pollDirectory(Exchange dynamic, String fileName, 
List<GenericFile<SftpRemoteFile>> fileList, int depth) {
+        // resolved lazily during this poll on the first symbolic link 
encountered, as the starting directory can be replaced between polls
+        resolvedStartingDirectory = null;
+        unresolvableLinks.clear();
+
         String currentDir = null;
         if (isStepwise()) {
             // must remember current dir so we stay in that directory after 
the poll
@@ -247,6 +271,120 @@ public abstract class AbstractSftpConsumer extends 
RemoteFileConsumer<SftpRemote
         return false;
     }
 
+    @Override
+    protected boolean 
isWithinStartingDirectory(Supplier<GenericFile<SftpRemoteFile>> file, String 
absoluteFilePath) {
+        if (!super.isWithinStartingDirectory(file, absoluteFilePath)) {
+            return false;
+        }
+        GenericFile<SftpRemoteFile> remoteFile = file.get();
+        if (remoteFile.getFile() == null || 
!remoteFile.getFile().isSymbolicLink()) {
+            return true;
+        }
+        // the server follows a symbolic link when the file is retrieved, so 
the target of a listed link must be within
+        // the starting directory too
+        if (!(operations instanceof SftpSymbolicLinkOperations links)) {
+            LOG.debug("Cannot resolve symbolic link: {} as the operations 
cannot read symbolic links", absoluteFilePath);
+            return false;
+        }
+        try {
+            String startingDirectory = resolvedStartingDirectory;
+            if (startingDirectory == null) {
+                startingDirectory = resolveSymbolicLinks(links, null, 
endpointPath);
+                resolvedStartingDirectory = startingDirectory;
+            }
+            // the path of the file below the starting directory is resolved 
from the resolved starting directory
+            String relativeFilePath = remoteFile.getRelativeFilePath();
+            String target = relativeFilePath != null
+                    ? resolveSymbolicLinks(links, startingDirectory, 
relativeFilePath)
+                    : resolveSymbolicLinks(links, null, 
remoteFile.getAbsoluteFilePath());
+            return GenericFileHelper.isWithinDirectory(target, 
startingDirectory, '/');
+        } catch (GenericFileOperationFailedException e) {
+            if (links.isTransportError(e)) {
+                throw e;
+            }
+            // such as a dangling symbolic link
+            unresolvableLinks.add(absoluteFilePath);
+            LOG.debug("Cannot resolve symbolic link: {} against the starting 
directory due to: {}", absoluteFilePath,
+                    e.getMessage());
+            return false;
+        }
+    }
+
+    @Override
+    protected boolean isUnresolvableLink(String absoluteFilePath) {
+        return unresolvableLinks.contains(absoluteFilePath);
+    }
+
+    /**
+     * Resolves a path on the server the way the server does when it opens the 
path: one segment at a time, replacing
+     * each symbolic link with its target, which is relative to the directory 
of the link unless it is absolute. The
+     * realpath request is not used for this, as some servers (such as Apache 
MINA SSHD) do not resolve links in it. A
+     * link whose target contains a backslash is not resolved, as a server may 
take a backslash as a path separator (as
+     * on Windows) or as part of a name, so the path that the server follows 
is not known.
+     *
+     * @param  operations the operations to look up the segments with
+     * @param  directory  the absolute path, without symbolic links, that a 
relative path is resolved against, or
+     *                    {@code null} for the home directory
+     * @param  path       the path to resolve
+     * @return            the absolute path, without symbolic links and 
without {@code .} and {@code ..} segments
+     */
+    private static String resolveSymbolicLinks(SftpSymbolicLinkOperations 
operations, String directory, String path) {
+        String name = path != null ? path : "";
+        Deque<String> resolved = new ArrayDeque<>();
+        Deque<String> pending = new ArrayDeque<>();
+        if (!name.startsWith("/")) {
+            if (directory != null) {
+                // the directory has no links to resolve
+                for (String segment : directory.split("/")) {
+                    if (!segment.isEmpty()) {
+                        resolved.addLast(segment);
+                    }
+                }
+            } else {
+                
pending.addAll(Arrays.asList(operations.getHomeDirectory().split("/")));
+            }
+        }
+        pending.addAll(Arrays.asList(name.split("/")));
+
+        int links = 0;
+        while (!pending.isEmpty()) {
+            String segment = pending.removeFirst();
+            if (segment.isEmpty() || ".".equals(segment)) {
+                continue;
+            }
+            if ("..".equals(segment)) {
+                resolved.pollLast();
+                continue;
+            }
+            String candidate = "/" + (resolved.isEmpty() ? "" : 
String.join("/", resolved) + "/") + segment;
+            if (operations.isSymbolicLink(candidate)) {
+                if (++links > MAX_SYMBOLIC_LINKS) {
+                    throw new GenericFileOperationFailedException("Too many 
levels of symbolic links: " + name);
+                }
+                String target = operations.readSymbolicLink(candidate);
+                if (ObjectHelper.isEmpty(target)) {
+                    throw new GenericFileOperationFailedException("Symbolic 
link without a target: " + candidate);
+                }
+                if (target.indexOf('\\') != -1) {
+                    // splitting on both separators would be wrong on a server 
that takes a backslash as part of a name
+                    throw new GenericFileOperationFailedException(
+                            "Symbolic link with a backslash in its target: " + 
candidate);
+                }
+                if (target.startsWith("/")) {
+                    resolved.clear();
+                }
+                // the segments of the target take the place of the link
+                String[] segments = target.split("/");
+                for (int i = segments.length - 1; i >= 0; i--) {
+                    pending.addFirst(segments[i]);
+                }
+            } else {
+                resolved.addLast(segment);
+            }
+        }
+        return "/" + String.join("/", resolved);
+    }
+
     @Override
     protected Supplier<String> getRelativeFilePath(String endpointPath, String 
path, String absolutePath, SftpRemoteFile file) {
         return () -> {
diff --git 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFile.java
 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFile.java
index f135525ba6c8..8a9efff88da3 100644
--- 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFile.java
+++ 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFile.java
@@ -41,6 +41,13 @@ public interface SftpRemoteFile<T> {
      */
     boolean isDirectory();
 
+    /**
+     * Whether the server listed it as a symbolic link
+     */
+    default boolean isSymbolicLink() {
+        return false;
+    }
+
     /**
      * The file size
      */
diff --git 
a/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpSymbolicLinkOperations.java
 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpSymbolicLinkOperations.java
new file mode 100644
index 000000000000..20481e124f91
--- /dev/null
+++ 
b/components/camel-ftp-common/src/main/java/org/apache/camel/component/file/remote/SftpSymbolicLinkOperations.java
@@ -0,0 +1,53 @@
+/*
+ * 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.component.file.GenericFileOperationFailedException;
+
+/**
+ * The operations of an SFTP server that the consumer uses to resolve the 
symbolic links of a listing.
+ */
+public interface SftpSymbolicLinkOperations {
+
+    /**
+     * The absolute path of the directory that the server resolves relative 
paths against, such as the home directory of
+     * the user.
+     */
+    String getHomeDirectory() throws GenericFileOperationFailedException;
+
+    /**
+     * Whether the path is a symbolic link, without following the link (the 
{@code lstat} request).
+     *
+     * @throws GenericFileOperationFailedException if the path does not exist 
or cannot be read
+     */
+    boolean isSymbolicLink(String path) throws 
GenericFileOperationFailedException;
+
+    /**
+     * The target of the symbolic link, as stored in the link (the {@code 
readlink} request).
+     *
+     * @throws GenericFileOperationFailedException if the path is not a 
symbolic link or cannot be read
+     */
+    String readSymbolicLink(String path) throws 
GenericFileOperationFailedException;
+
+    /**
+     * Whether the failure is due to a transport or connection error rather 
than an unresolvable symbolic link.
+     */
+    default boolean isTransportError(GenericFileOperationFailedException e) {
+        return false;
+    }
+
+}
diff --git a/components/camel-ftp/src/main/docs/sftp-component.adoc 
b/components/camel-ftp/src/main/docs/sftp-component.adoc
index 7522a5d1d490..f893b297d3c3 100644
--- a/components/camel-ftp/src/main/docs/sftp-component.adoc
+++ b/components/camel-ftp/src/main/docs/sftp-component.adoc
@@ -311,6 +311,21 @@ ssh -vvv <server_address>
 
 As of Camel 3.18.1, these values can also be set on SFTP endpoints by setting 
the corresponding URI parameters.
 
+=== Symbolic links and the starting directory
+
+With `jailStartingDirectory` enabled (the default), the consumer resolves a 
listed file that the server reports as a
+symbolic link before it accepts the file. A link whose target is outside the 
starting directory, or cannot be resolved
+(such as a dangling link), is skipped and logged at WARN the first time. A 
link whose target contains a backslash (`\`)
+is skipped too, as a server may take it as a path separator or as part of a 
name. Links that resolve inside the
+starting directory, and a starting directory that is itself a link, work as 
usual. Set `jailStartingDirectory=false` to
+follow links to other directories.
+
+The link is resolved on the server one path segment at a time, with the 
`lstat` and `readlink` requests, so each listed
+link takes a few more requests per poll; other files take none. Only the files 
that the listing reports as links are
+resolved: OpenSSH and Apache MINA SSHD list a link as a link, but a server 
that lists the attributes of the target
+instead does not show it as a link, and with `useList=false` there is no 
listing. The check and the download that
+follows are not atomic, so the permissions and link settings of the SFTP 
server still matter.
+
 == More Information
 
 For more information, you can look at the xref:ftp-component.adoc[FTP 
component].
diff --git 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
index 36eff0fbd983..5f1f54603931 100644
--- 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
+++ 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpOperations.java
@@ -79,7 +79,7 @@ import static org.apache.camel.util.ObjectHelper.isNotEmpty;
  * <p/>
  * The JSCH session and channel are not thread-safe so we need to synchronize 
access to using this operation.
  */
-public class SftpOperations implements RemoteFileOperations<SftpRemoteFile> {
+public class SftpOperations implements RemoteFileOperations<SftpRemoteFile>, 
SftpSymbolicLinkOperations {
     private static final Logger LOG = 
LoggerFactory.getLogger(SftpOperations.class);
     private static final Pattern UP_DIR_PATTERN = Pattern.compile("/[^/]+");
     private static final int OK_STATUS = 0;
@@ -939,6 +939,86 @@ public class SftpOperations implements 
RemoteFileOperations<SftpRemoteFile> {
         }
     }
 
+    @Override
+    public String getHomeDirectory() throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            return channel.getHome();
+        } catch (SftpException e) {
+            throw new GenericFileOperationFailedException("Cannot get the home 
directory", e);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public boolean isSymbolicLink(String path) throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            return channel.lstat(quotePathForJsch(path)).isLink();
+        } catch (SftpException e) {
+            throw new GenericFileOperationFailedException("Cannot get the 
attributes of: " + path, e);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public String readSymbolicLink(String path) throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            return channel.readlink(quotePathForJsch(path));
+        } catch (SftpException e) {
+            throw new GenericFileOperationFailedException("Cannot read the 
symbolic link: " + path, e);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public boolean isTransportError(GenericFileOperationFailedException e) {
+        if (!isConnected()) {
+            return true;
+        }
+        // JSch reports a status sent by the server as an SftpException 
without a cause, and wraps an I/O or session
+        // failure as an SftpException (SSH_FX_FAILURE) with that failure as 
its cause
+        return e.getCause() instanceof SftpException sftp && sftp.getCause() 
!= null;
+    }
+
+    /**
+     * Escapes the characters that JSch interprets in a path argument, so that 
the server acts on the literal path.
+     * <p/>
+     * {@link ChannelSftp#lstat(String)} and {@link 
ChannelSftp#readlink(String)} pass the path through JSch's
+     * {@code isUnique}/{@code glob_remote}, which treats {@code *} and {@code 
?} as wildcards and {@code \} as an
+     * escape character and removes it. Without this, a listed link whose 
target contains one of these characters (for
+     * example a target that steps through a sibling symbolic link named with 
a backslash) would check a different path
+     * from the one the server opens when retrieving the file, which would let 
the jail check be bypassed. This mirrors
+     * JSch's own (package-private) {@code Util.quote}.
+     *
+     * @param  path the literal path to look up
+     * @return      the path with {@code \}, {@code *} and {@code ?} escaped 
for JSch
+     */
+    private static String quotePathForJsch(String path) {
+        if (path == null) {
+            return null;
+        }
+        StringBuilder quoted = null;
+        for (int i = 0; i < path.length(); i++) {
+            char c = path.charAt(i);
+            if (c == '\\' || c == '*' || c == '?') {
+                if (quoted == null) {
+                    quoted = new StringBuilder(path.length() + 8);
+                    quoted.append(path, 0, i);
+                }
+                quoted.append('\\');
+            }
+            if (quoted != null) {
+                quoted.append(c);
+            }
+        }
+        return quoted != null ? quoted.toString() : path;
+    }
+
     @Override
     public boolean retrieveFile(String name, Exchange exchange, long size)
             throws GenericFileOperationFailedException {
diff --git 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFileJCraft.java
 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFileJCraft.java
index 189650a0cbc9..8ab5476fff2d 100644
--- 
a/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFileJCraft.java
+++ 
b/components/camel-ftp/src/main/java/org/apache/camel/component/file/remote/SftpRemoteFileJCraft.java
@@ -46,6 +46,11 @@ public class SftpRemoteFileJCraft implements 
SftpRemoteFile<ChannelSftp.LsEntry>
         return entry.getAttrs().isDir();
     }
 
+    @Override
+    public boolean isSymbolicLink() {
+        return entry.getAttrs().isLink();
+    }
+
     @Override
     public long getFileLength() {
         return entry.getAttrs().getSize();
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
index c4a8008a597c..c46ac16334f7 100644
--- 
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
@@ -16,16 +16,31 @@
  */
 package org.apache.camel.component.file.remote;
 
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import com.jcraft.jsch.ChannelSftp;
+import com.jcraft.jsch.SftpException;
+import org.apache.camel.component.file.GenericFile;
+import org.apache.camel.component.file.GenericFileOperationFailedException;
 import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.util.FileUtil;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 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.
+ * polled before it is used as the operand for retrieving, deleting or 
renaming. A listed file that the SFTP server
+ * reports as a symbolic link is followed by the server when it is retrieved, 
so its target must be inside the directory
+ * too.
  */
 class RemoteFileConsumerStartingDirectoryJailTest extends CamelTestSupport {
 
@@ -35,6 +50,12 @@ class RemoteFileConsumerStartingDirectoryJailTest extends 
CamelTestSupport {
         });
     }
 
+    private AbstractSftpConsumer sftpConsumer(String uri, SftpOperations 
operations) {
+        SftpEndpoint endpoint = context.getEndpoint(uri, SftpEndpoint.class);
+        return new SftpConsumer(endpoint, exchange -> {
+        }, operations, null);
+    }
+
     @Test
     void shouldAcceptPathsWithinTheStartingDirectory() throws Exception {
         RemoteFileConsumer<?> consumer = consumer("ftp://hostname/poll";);
@@ -74,4 +95,236 @@ class RemoteFileConsumerStartingDirectoryJailTest extends 
CamelTestSupport {
         // no directory is configured, but navigating above the session root 
still escapes
         assertFalse(consumer.isWithinStartingDirectory("../secret.txt"));
     }
+
+    @Test
+    void shouldNotResolveListedFilesThatAreNotSymbolicLinks() {
+        StubSftpOperations server = new 
StubSftpOperations().file("/home/camel/poll/hello.txt");
+        AbstractSftpConsumer consumer = sftpConsumer("sftp://hostname/poll";, 
server);
+
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"hello.txt", false), "poll/hello.txt"));
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"sub/hello.txt", false), "poll/sub/hello.txt"));
+        // the path built from the listed name is still checked
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"../hello.txt", false), "poll/../hello.txt"));
+        // without asking the server
+        assertEquals(0, server.lookups);
+    }
+
+    @Test
+    void shouldAcceptSymbolicLinkResolvingInsideTheStartingDirectory() {
+        StubSftpOperations server = new StubSftpOperations()
+                .file("/home/camel/poll/hello.txt")
+                .file("/home/camel/poll/data/report.txt")
+                .link("/home/camel/poll/latest.txt", "hello.txt")
+                .link("/home/camel/poll/report.txt", 
"/home/camel/poll/data/report.txt")
+                .link("/home/camel/poll/current", "data")
+                .link("/home/camel/poll/current.txt", "current/report.txt")
+                .link("/home/camel/poll/again.txt", "../poll/latest.txt")
+                .link("/home/camel/poll/data/hello.txt", "../hello.txt");
+        AbstractSftpConsumer consumer = sftpConsumer("sftp://hostname/poll";, 
server);
+
+        // relative to the directory of the link, absolute, through a link to 
a directory, and to another link
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"latest.txt", true), "poll/latest.txt"));
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"report.txt", true), "poll/report.txt"));
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"current.txt", true), "poll/current.txt"));
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"again.txt", true), "poll/again.txt"));
+        // listed in a sub directory (recursive)
+        assertTrue(consumer.isWithinStartingDirectory(listed("poll", 
"data/hello.txt", true), "poll/data/hello.txt"));
+
+        // a starting directory that is itself a link
+        StubSftpOperations linkedServer = new StubSftpOperations()
+                .file("/srv/inbox/hello.txt")
+                .link("/home/camel/inbox", "/srv/inbox")
+                .link("/srv/inbox/latest.txt", "hello.txt");
+        AbstractSftpConsumer linkedConsumer = 
sftpConsumer("sftp://hostname/inbox";, linkedServer);
+
+        assertTrue(linkedConsumer.isWithinStartingDirectory(listed("inbox", 
"latest.txt", true), "inbox/latest.txt"));
+    }
+
+    @Test
+    void shouldSkipSymbolicLinkResolvingOutsideTheStartingDirectory() {
+        StubSftpOperations server = new StubSftpOperations()
+                .file("/home/camel/poll/hello.txt")
+                .file("/home/camel/archive/report.txt")
+                .file("/home/camel/polling/report.txt")
+                .link("/home/camel/poll/report.txt", "../archive/report.txt")
+                .link("/home/camel/poll/absolute.txt", 
"/home/camel/archive/report.txt")
+                .link("/home/camel/poll/archive", "../archive")
+                .link("/home/camel/poll/through.txt", "archive/report.txt")
+                .link("/home/camel/poll/polling.txt", "../polling/report.txt")
+                .link("/home/camel/poll/sub/report.txt", 
"../../archive/report.txt");
+        AbstractSftpConsumer consumer = sftpConsumer("sftp://hostname/poll";, 
server);
+
+        // relative upwards, absolute, and through a link to a directory 
outside
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"report.txt", true), "poll/report.txt"));
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"absolute.txt", true), "poll/absolute.txt"));
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"through.txt", true), "poll/through.txt"));
+        // a sibling directory whose name merely extends the polled directory 
name is not contained
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"polling.txt", true), "poll/polling.txt"));
+        // listed in a sub directory (recursive)
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"sub/report.txt", true), "poll/sub/report.txt"));
+    }
+
+    @Test
+    void shouldSkipSymbolicLinkThatCannotBeResolved() {
+        StubSftpOperations server = new StubSftpOperations()
+                .file("/home/camel/poll/hello.txt")
+                .link("/home/camel/poll/missing.txt", "gone.txt")
+                .link("/home/camel/poll/ping.txt", "pong.txt")
+                .link("/home/camel/poll/pong.txt", "ping.txt")
+                .link("/home/camel/poll/empty.txt", "");
+        AbstractSftpConsumer consumer = sftpConsumer("sftp://hostname/poll";, 
server);
+
+        // a dangling link, a loop, and a link without a target
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"missing.txt", true), "poll/missing.txt"));
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"ping.txt", true), "poll/ping.txt"));
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"empty.txt", true), "poll/empty.txt"));
+    }
+
+    @Test
+    void shouldSkipSymbolicLinkWhoseTargetContainsBackslash() {
+        // a server that takes a backslash as a path separator too, as on 
Windows
+        StubSftpOperations server = new 
StubSftpOperations().backslashSeparator()
+                .file("/home/camel/poll/hello.txt")
+                .file("/home/camel/poll/sub/file.txt")
+                .file("/home/outside.txt")
+                .link("/home/camel/poll/report.txt", "..\\..\\outside.txt")
+                .link("/home/camel/poll/inside.txt", "sub\\file.txt");
+        AbstractSftpConsumer consumer = sftpConsumer("sftp://hostname/poll";, 
server);
+
+        // the server follows the first link out of the starting directory, 
and the second one inside, but which path
+        // a server follows for a backslash depends on the server, so both are 
skipped as links that cannot be resolved
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"report.txt", true), "poll/report.txt"));
+        assertTrue(consumer.isUnresolvableLink("poll/report.txt"));
+        assertFalse(consumer.isWithinStartingDirectory(listed("poll", 
"inside.txt", true), "poll/inside.txt"));
+        assertTrue(consumer.isUnresolvableLink("poll/inside.txt"));
+
+        // a server that takes a backslash as part of a name
+        StubSftpOperations literalServer = new StubSftpOperations()
+                .file("/home/camel/poll/a\\b.txt")
+                .link("/home/camel/poll/literal.txt", "a\\b.txt");
+        AbstractSftpConsumer literalConsumer = 
sftpConsumer("sftp://hostname/poll";, literalServer);
+
+        assertFalse(literalConsumer.isWithinStartingDirectory(listed("poll", 
"literal.txt", true), "poll/literal.txt"));
+        assertTrue(literalConsumer.isUnresolvableLink("poll/literal.txt"));
+    }
+
+    @Test
+    void isTransportErrorClassification() {
+        SftpOperations disconnected = new SftpOperations();
+        assertTrue(disconnected.isTransportError(new 
GenericFileOperationFailedException("disconnected")),
+                "Disconnected operations should classify any failure as 
transport error");
+
+        SftpOperations connected = new SftpOperations() {
+            @Override
+            public boolean isConnected() {
+                return true;
+            }
+        };
+
+        // JSch wraps an I/O or session failure as SSH_FX_FAILURE with that 
failure as the cause
+        assertTrue(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "broken pipe", new SftpException(
+                        ChannelSftp.SSH_FX_FAILURE, "java.io.IOException: 
broken pipe", new IOException("broken pipe")))));
+
+        // A status sent by the server is not a transport error, whatever its 
code. JSch never raises the
+        // SSH_FX_CONNECTION_LOST and SSH_FX_NO_CONNECTION pseudo-errors 
itself, so they can only be such a status too
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "connection lost", new 
SftpException(ChannelSftp.SSH_FX_CONNECTION_LOST, "connection lost"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "no connection", new 
SftpException(ChannelSftp.SSH_FX_NO_CONNECTION, "no connection"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "no such file", new 
SftpException(ChannelSftp.SSH_FX_NO_SUCH_FILE, "no such file"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "permission denied", new 
SftpException(ChannelSftp.SSH_FX_PERMISSION_DENIED, "permission denied"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "failure", new SftpException(ChannelSftp.SSH_FX_FAILURE, 
"failure"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "bad message", new 
SftpException(ChannelSftp.SSH_FX_BAD_MESSAGE, "bad message"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "op unsupported", new 
SftpException(ChannelSftp.SSH_FX_OP_UNSUPPORTED, "op unsupported"))));
+    }
+
+    private static Supplier<GenericFile<SftpRemoteFile>> listed(
+            String directory, String relativeFilePath, boolean symbolicLink) {
+        RemoteFile<SftpRemoteFile> file = new RemoteFile<>();
+        file.setFile(new 
SftpRemoteFileSingle(FileUtil.stripPath(relativeFilePath)) {
+            @Override
+            public boolean isSymbolicLink() {
+                return symbolicLink;
+            }
+        });
+        file.setEndpointPath(directory);
+        file.setRelativeFilePath(relativeFilePath);
+        file.setAbsoluteFilePath(directory + "/" + relativeFilePath);
+        return () -> file;
+    }
+
+    /**
+     * The files of an SFTP server whose home directory is /home/camel: the 
paths that exist, and the symbolic links
+     * among them.
+     */
+    private static final class StubSftpOperations extends SftpOperations {
+
+        private final Set<String> files = new HashSet<>();
+        private final Map<String, String> links = new HashMap<>();
+        private int lookups;
+        private boolean backslashSeparator;
+
+        /**
+         * Takes a backslash as a path separator too when looking up a path, 
as a server on Windows does.
+         */
+        StubSftpOperations backslashSeparator() {
+            backslashSeparator = true;
+            return this;
+        }
+
+        StubSftpOperations file(String path) {
+            // the file and the directories it is in
+            for (String name = path; name != null && !"/".equals(name); name = 
FileUtil.onlyPath(name)) {
+                files.add(name);
+            }
+            return this;
+        }
+
+        StubSftpOperations link(String path, String target) {
+            file(FileUtil.onlyPath(path));
+            links.put(path, target);
+            return this;
+        }
+
+        @Override
+        public boolean isConnected() {
+            return true;
+        }
+
+        @Override
+        public String getHomeDirectory() {
+            return "/home/camel";
+        }
+
+        @Override
+        public boolean isSymbolicLink(String path) {
+            lookups++;
+            String name = serverPath(path);
+            if (links.containsKey(name)) {
+                return true;
+            }
+            if (files.contains(name)) {
+                return false;
+            }
+            throw new GenericFileOperationFailedException("No such file: " + 
path);
+        }
+
+        @Override
+        public String readSymbolicLink(String path) {
+            return links.get(serverPath(path));
+        }
+
+        /**
+         * The path that the server looks up for the path of a request.
+         */
+        private String serverPath(String path) {
+            return backslashSeparator ? 
FileUtil.compactPath(path.replace('\\', '/'), '/') : path;
+        }
+    }
 }
diff --git 
a/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/sftp/integration/SftpConsumerJailStartingDirectorySymlinkIT.java
 
b/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/sftp/integration/SftpConsumerJailStartingDirectorySymlinkIT.java
new file mode 100644
index 000000000000..7c49615b6ab5
--- /dev/null
+++ 
b/components/camel-ftp/src/test/java/org/apache/camel/component/file/remote/sftp/integration/SftpConsumerJailStartingDirectorySymlinkIT.java
@@ -0,0 +1,291 @@
+/*
+ * 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.sftp.integration;
+
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystemException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * With jailStartingDirectory (the default) the SFTP consumer resolves a 
listed symbolic link on the server, and only
+ * consumes it when its target is inside the starting directory.
+ */
+@EnabledIf(value = 
"org.apache.camel.test.infra.ftp.services.embedded.SftpUtil#hasRequiredAlgorithms('src/test/resources/hostkey.pem')")
+class SftpConsumerJailStartingDirectorySymlinkIT extends SftpServerTestSupport 
{
+
+    private Path base;
+
+    @BeforeEach
+    void createTestDirectory() throws IOException {
+        // the root directory of the server is shared by the tests of this 
class and kept between runs
+        base = 
Files.createTempDirectory(Files.createDirectories(service.getFtpRootDir()), 
"test");
+    }
+
+    @Test
+    void consumesLinkResolvingInsideStartingDirectory() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("hello.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "latest.txt");
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsLinkResolvingOutsideStartingDirectory() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("report.txt"), Path.of("..", 
"archive", "report.txt"));
+        createSymbolicLink(inbox.resolve("absolute.txt"), 
archive.resolve("report.txt").toAbsolutePath());
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the links are not part of the batch of the poll that picked up 
hello.txt
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void followsLinkOutsideStartingDirectoryWhenJailDisabled() throws 
Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("report.txt"), Path.of("..", 
"archive", "report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Archived");
+
+        consume("inbox", "&jailStartingDirectory=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsDanglingLink() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("missing.txt"), Path.of("gone.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the link is not part of the batch of the poll that picked up 
hello.txt
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void consumesLinkInStartingDirectoryReachedThroughLink() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("hello.txt"));
+        createSymbolicLink(base.resolve("inbox-link"), Path.of("inbox"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "latest.txt");
+
+        consume("inbox-link", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void 
skipsLinkWhoseTargetStepsThroughBackslashNamedLinkOutsideStartingDirectory() 
throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path outside = Files.createDirectories(base.resolve("outside"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(outside.resolve("report.txt"), "Outside");
+        // a real directory inside the starting directory, named as the link 
below with its backslash removed, with a
+        // file at the path that a check which drops the backslash would 
resolve and accept as inside the jail
+        
Files.writeString(Files.createDirectories(inbox.resolve("pivotx")).resolve("report.txt"),
 "Inside");
+        // a symbolic link whose name contains a backslash, pointing outside 
the starting directory
+        createSymbolicLink(inbox.resolve("pivot\\x"), Path.of("..", 
"outside"));
+        // a link whose stored target steps through the backslash-named link; 
the server follows it to the outside file
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("pivot\\x", 
"report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the links are not part of the batch of the poll that picked up 
hello.txt (latest.txt is skipped, not consumed)
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows does not allow 
* and ? in file names")
+    void consumesLinksNamedWithWildcardsResolvingInsideStartingDirectory() 
throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("star*.txt"), Path.of("hello.txt"));
+        createSymbolicLink(inbox.resolve("what?.txt"), Path.of("hello.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World", "Hello 
World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "star*.txt", "what?.txt");
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows does not allow 
* and ? in file names")
+    void consumesLinksResolvingInsideThroughWildcardNamedDirectories() throws 
Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        // read as a pattern, each of the two directory names matches both 
directories
+        
Files.writeString(Files.createDirectories(inbox.resolve("sub*dir")).resolve("report.txt"),
 "Star");
+        
Files.writeString(Files.createDirectories(inbox.resolve("sub?dir")).resolve("report.txt"),
 "Question");
+        createSymbolicLink(inbox.resolve("star.txt"), Path.of("sub*dir", 
"report.txt"));
+        createSymbolicLink(inbox.resolve("question.txt"), Path.of("sub?dir", 
"report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("Hello World", "Star", 
"Question");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "star.txt", "question.txt");
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows does not allow 
* and ? in file names")
+    void skipsLinksNamedWithWildcardsResolvingOutsideStartingDirectory() 
throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("star*.txt"), Path.of("..", 
"archive", "report.txt"));
+        createSymbolicLink(inbox.resolve("what?.txt"), Path.of("..", 
"archive", "report.txt"));
+        // a link to the directory outside, and a link whose target steps 
through it
+        createSymbolicLink(inbox.resolve("arch*ve"), Path.of("..", "archive"));
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("arch*ve", 
"report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the links are not part of the batch of the poll that picked up 
hello.txt
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsLinkResolvingOutsideStartingDirectoryStepwiseFalse() throws 
Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("report.txt"), Path.of("..", 
"archive", "report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "&stepwise=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsDanglingLinkStepwiseFalse() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("missing.txt"), Path.of("gone.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "&stepwise=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsLinkResolvingOutsideInSubDirectoryRecursive() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path sub = Files.createDirectories(inbox.resolve("sub"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(sub.resolve("report.txt"), Path.of("..", "..", 
"archive", "report.txt"));
+        createSymbolicLink(sub.resolve("inner.txt"), Path.of("..", 
"hello.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("Hello World", "Hello World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "sub/inner.txt");
+
+        consume("inbox", "&recursive=true");
+
+        mock.assertIsSatisfied();
+    }
+
+    private void consume(String directory, String query) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("sftp://localhost:"; + service.getPort() + "/" + 
base.resolve(directory)
+                     + "?username=admin&password=admin&knownHostsFile=" + 
service.getKnownHostsFile()
+                     + "&initialDelay=0&delay=100&noop=true" + query)
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+    }
+
+    private static void createSymbolicLink(Path link, Path target) {
+        try {
+            Files.createSymbolicLink(link, target);
+        } catch (FileAlreadyExistsException e) {
+            throw new IllegalStateException(e);
+        } catch (UnsupportedOperationException | FileSystemException e) {
+            // such as Windows when the user may not create symbolic links
+            Assumptions.abort("Symbolic links cannot be created on this 
platform: " + e.getMessage());
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git a/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc 
b/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc
index 8f282d231c19..d741b118a0c3 100644
--- a/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc
+++ b/components/camel-mina-sftp/src/main/docs/mina-sftp-component.adoc
@@ -641,7 +641,17 @@ IMPORTANT: The `chmod` and `chmodDirectory` options only 
work on POSIX-compatibl
 
 == Symbolic Links
 
-The consumer follows symbolic links to their target files. The producer writes 
to symlink targets.
+The consumer follows symbolic links to their target files, within the starting 
directory unless `jailStartingDirectory=false` (see below). The producer writes 
to symlink targets.
+
+With `jailStartingDirectory` enabled (the default), the consumer resolves a 
listed file that the server reports as a
+symbolic link before it accepts the file, one path segment at a time with the 
`lstat` and `readlink` requests. A link
+whose target is outside the starting directory, or cannot be resolved (such as 
a dangling link), is skipped and logged
+at WARN the first time. A link whose target contains a backslash (`\`) is 
skipped too, as a server may take it as a
+path separator or as part of a name. Links that resolve inside the starting 
directory, and a starting directory that is
+itself a link, work as usual. Set `jailStartingDirectory=false` to follow 
links to other directories, such as the links
+in `data` below when `data` is the starting directory. Only the files that the 
listing reports as links are resolved (a
+server that lists the attributes of the target instead does not show them as 
links), and the check and the download
+that follows are not atomic, so the permissions and link settings of the SFTP 
server still matter.
 
 NOTE: **Absolute symlinks in chroot environments**: If the SFTP server uses a 
chroot jail, absolute symlinks may not resolve correctly. Use **relative 
symlinks** for maximum compatibility.
 
diff --git 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
index 7b055685059c..39eca343f206 100644
--- 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
+++ 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpOperations.java
@@ -50,6 +50,7 @@ import org.apache.camel.component.file.remote.RemoteFile;
 import org.apache.camel.component.file.remote.RemoteFileConfiguration;
 import org.apache.camel.component.file.remote.RemoteFileOperations;
 import org.apache.camel.component.file.remote.SftpRemoteFile;
+import org.apache.camel.component.file.remote.SftpSymbolicLinkOperations;
 import org.apache.camel.support.task.BlockingTask;
 import org.apache.camel.support.task.Tasks;
 import org.apache.camel.support.task.budget.Budgets;
@@ -77,6 +78,8 @@ import org.apache.sshd.sftp.client.SftpClient;
 import org.apache.sshd.sftp.client.SftpClient.DirEntry;
 import org.apache.sshd.sftp.client.SftpClient.OpenMode;
 import org.apache.sshd.sftp.client.SftpClientFactory;
+import org.apache.sshd.sftp.common.SftpConstants;
+import org.apache.sshd.sftp.common.SftpException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -85,7 +88,7 @@ import org.slf4j.LoggerFactory;
  *
  * The SSHD session and SFTP client are not thread-safe so we need to 
synchronize access to using this operation.
  */
-public class MinaSftpOperations implements 
RemoteFileOperations<SftpRemoteFile> {
+public class MinaSftpOperations implements 
RemoteFileOperations<SftpRemoteFile>, SftpSymbolicLinkOperations {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(MinaSftpOperations.class);
     private static final Pattern UP_DIR_PATTERN = Pattern.compile("/[^/]+");
@@ -1180,6 +1183,56 @@ public class MinaSftpOperations implements 
RemoteFileOperations<SftpRemoteFile>
         }
     }
 
+    @Override
+    public String getHomeDirectory() throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            // the server resolves relative paths against its default directory
+            return sftpClient.canonicalPath(".");
+        } catch (IOException e) {
+            throw new GenericFileOperationFailedException("Cannot get the home 
directory", e);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public boolean isSymbolicLink(String path) throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            return sftpClient.lstat(path).isSymbolicLink();
+        } catch (IOException e) {
+            throw new GenericFileOperationFailedException("Cannot get the 
attributes of: " + path, e);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public String readSymbolicLink(String path) throws 
GenericFileOperationFailedException {
+        lock.lock();
+        try {
+            return sftpClient.readLink(path);
+        } catch (IOException e) {
+            throw new GenericFileOperationFailedException("Cannot read the 
symbolic link: " + path, e);
+        } finally {
+            lock.unlock();
+        }
+    }
+
+    @Override
+    public boolean isTransportError(GenericFileOperationFailedException e) {
+        if (!isConnected()) {
+            return true;
+        }
+        Throwable cause = e.getCause();
+        if (cause instanceof SftpException sftp) {
+            return sftp.getStatus() == SftpConstants.SSH_FX_NO_CONNECTION
+                    || sftp.getStatus() == 
SftpConstants.SSH_FX_CONNECTION_LOST;
+        }
+        return cause instanceof IOException;
+    }
+
     @Override
     public boolean retrieveFile(String name, Exchange exchange, long size)
             throws GenericFileOperationFailedException {
diff --git 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpRemoteFile.java
 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpRemoteFile.java
index 7db395c8d02a..2fae31f91aa1 100644
--- 
a/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpRemoteFile.java
+++ 
b/components/camel-mina-sftp/src/main/java/org/apache/camel/component/file/remote/mina/MinaSftpRemoteFile.java
@@ -50,6 +50,11 @@ public class MinaSftpRemoteFile implements 
SftpRemoteFile<SftpClient.DirEntry> {
         return entry.getAttributes().isDirectory();
     }
 
+    @Override
+    public boolean isSymbolicLink() {
+        return entry.getAttributes().isSymbolicLink();
+    }
+
     @Override
     public long getFileLength() {
         return entry.getAttributes().getSize();
diff --git 
a/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
index f89078116f95..55a5ea74e6a2 100644
--- 
a/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
+++ 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/MinaSftpOperationsDisconnectTest.java
@@ -16,9 +16,13 @@
  */
 package org.apache.camel.component.file.remote.mina;
 
+import java.io.IOException;
 import java.lang.reflect.Field;
 
+import org.apache.camel.component.file.GenericFileOperationFailedException;
 import org.apache.sshd.client.SshClient;
+import org.apache.sshd.sftp.common.SftpConstants;
+import org.apache.sshd.sftp.common.SftpException;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -122,6 +126,40 @@ class MinaSftpOperationsDisconnectTest {
         operations.disconnect();
     }
 
+    @Test
+    void testIsTransportErrorClassification() {
+        MinaSftpOperations disconnected = new MinaSftpOperations();
+        assertTrue(disconnected.isTransportError(new 
GenericFileOperationFailedException("disconnected")),
+                "Disconnected operations should classify any failure as 
transport error");
+
+        MinaSftpOperations connected = new MinaSftpOperations() {
+            @Override
+            public boolean isConnected() {
+                return true;
+            }
+        };
+
+        // Whitelisted transport errors
+        assertTrue(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "connection lost", new 
SftpException(SftpConstants.SSH_FX_CONNECTION_LOST, "connection lost"))));
+        assertTrue(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "no connection", new 
SftpException(SftpConstants.SSH_FX_NO_CONNECTION, "no connection"))));
+        assertTrue(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "io exception", new IOException("broken pipe"))));
+
+        // Application-level errors are not transport errors
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "no such file", new 
SftpException(SftpConstants.SSH_FX_NO_SUCH_FILE, "no such file"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "permission denied", new 
SftpException(SftpConstants.SSH_FX_PERMISSION_DENIED, "permission denied"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "failure", new SftpException(SftpConstants.SSH_FX_FAILURE, 
"failure"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "bad message", new 
SftpException(SftpConstants.SSH_FX_BAD_MESSAGE, "bad message"))));
+        assertFalse(connected.isTransportError(new 
GenericFileOperationFailedException(
+                "op unsupported", new 
SftpException(SftpConstants.SSH_FX_OP_UNSUPPORTED, "op unsupported"))));
+    }
+
     private static void setField(Object target, String fieldName, Object 
value) throws Exception {
         Field field = target.getClass().getDeclaredField(fieldName);
         field.setAccessible(true);
diff --git 
a/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/integration/MinaSftpConsumerJailStartingDirectorySymlinkIT.java
 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/integration/MinaSftpConsumerJailStartingDirectorySymlinkIT.java
new file mode 100644
index 000000000000..7435d280b396
--- /dev/null
+++ 
b/components/camel-mina-sftp/src/test/java/org/apache/camel/component/file/remote/mina/integration/MinaSftpConsumerJailStartingDirectorySymlinkIT.java
@@ -0,0 +1,291 @@
+/*
+ * 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.mina.integration;
+
+import java.io.IOException;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileSystemException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.condition.OS;
+
+/**
+ * With jailStartingDirectory (the default) the mina-sftp consumer resolves a 
listed symbolic link on the server, and
+ * only consumes it when its target is inside the starting directory.
+ */
+@EnabledIf(value = 
"org.apache.camel.test.infra.ftp.services.embedded.SftpUtil#hasRequiredAlgorithms('src/test/resources/hostkey.pem')")
+class MinaSftpConsumerJailStartingDirectorySymlinkIT extends 
MinaSftpServerTestSupport {
+
+    private Path base;
+
+    @BeforeEach
+    void createTestDirectory() throws IOException {
+        // the root directory of the server is shared by the tests of this 
class and kept between runs
+        base = 
Files.createTempDirectory(Files.createDirectories(service.getFtpRootDir()), 
"test");
+    }
+
+    @Test
+    void consumesLinkResolvingInsideStartingDirectory() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("hello.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "latest.txt");
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsLinkResolvingOutsideStartingDirectory() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("report.txt"), Path.of("..", 
"archive", "report.txt"));
+        createSymbolicLink(inbox.resolve("absolute.txt"), 
archive.resolve("report.txt").toAbsolutePath());
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the links are not part of the batch of the poll that picked up 
hello.txt
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void followsLinkOutsideStartingDirectoryWhenJailDisabled() throws 
Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("report.txt"), Path.of("..", 
"archive", "report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Archived");
+
+        consume("inbox", "&jailStartingDirectory=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsDanglingLink() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("missing.txt"), Path.of("gone.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the link is not part of the batch of the poll that picked up 
hello.txt
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void consumesLinkInStartingDirectoryReachedThroughLink() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("hello.txt"));
+        createSymbolicLink(base.resolve("inbox-link"), Path.of("inbox"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "latest.txt");
+
+        consume("inbox-link", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void 
skipsLinkWhoseTargetStepsThroughBackslashNamedLinkOutsideStartingDirectory() 
throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path outside = Files.createDirectories(base.resolve("outside"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(outside.resolve("report.txt"), "Outside");
+        // a real directory inside the starting directory, named as the link 
below with its backslash removed, with a
+        // file at the path that a check which drops the backslash would 
resolve and accept as inside the jail
+        
Files.writeString(Files.createDirectories(inbox.resolve("pivotx")).resolve("report.txt"),
 "Inside");
+        // a symbolic link whose name contains a backslash, pointing outside 
the starting directory
+        createSymbolicLink(inbox.resolve("pivot\\x"), Path.of("..", 
"outside"));
+        // a link whose stored target steps through the backslash-named link; 
the server follows it to the outside file
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("pivot\\x", 
"report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the links are not part of the batch of the poll that picked up 
hello.txt (latest.txt is skipped, not consumed)
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows does not allow 
* and ? in file names")
+    void consumesLinksNamedWithWildcardsResolvingInsideStartingDirectory() 
throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("star*.txt"), Path.of("hello.txt"));
+        createSymbolicLink(inbox.resolve("what?.txt"), Path.of("hello.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World", "Hello 
World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "star*.txt", "what?.txt");
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows does not allow 
* and ? in file names")
+    void consumesLinksResolvingInsideThroughWildcardNamedDirectories() throws 
Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        // read as a pattern, each of the two directory names matches both 
directories
+        
Files.writeString(Files.createDirectories(inbox.resolve("sub*dir")).resolve("report.txt"),
 "Star");
+        
Files.writeString(Files.createDirectories(inbox.resolve("sub?dir")).resolve("report.txt"),
 "Question");
+        createSymbolicLink(inbox.resolve("star.txt"), Path.of("sub*dir", 
"report.txt"));
+        createSymbolicLink(inbox.resolve("question.txt"), Path.of("sub?dir", 
"report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("Hello World", "Star", 
"Question");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "star.txt", "question.txt");
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Windows does not allow 
* and ? in file names")
+    void skipsLinksNamedWithWildcardsResolvingOutsideStartingDirectory() 
throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("star*.txt"), Path.of("..", 
"archive", "report.txt"));
+        createSymbolicLink(inbox.resolve("what?.txt"), Path.of("..", 
"archive", "report.txt"));
+        // a link to the directory outside, and a link whose target steps 
through it
+        createSymbolicLink(inbox.resolve("arch*ve"), Path.of("..", "archive"));
+        createSymbolicLink(inbox.resolve("latest.txt"), Path.of("arch*ve", 
"report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        // the links are not part of the batch of the poll that picked up 
hello.txt
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsLinkResolvingOutsideStartingDirectoryStepwiseFalse() throws 
Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(inbox.resolve("report.txt"), Path.of("..", 
"archive", "report.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "&stepwise=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsDanglingLinkStepwiseFalse() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("missing.txt"), Path.of("gone.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("inbox", "&stepwise=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void skipsLinkResolvingOutsideInSubDirectoryRecursive() throws Exception {
+        Path inbox = Files.createDirectories(base.resolve("inbox"));
+        Path sub = Files.createDirectories(inbox.resolve("sub"));
+        Path archive = Files.createDirectories(base.resolve("archive"));
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Files.writeString(archive.resolve("report.txt"), "Archived");
+        createSymbolicLink(sub.resolve("report.txt"), Path.of("..", "..", 
"archive", "report.txt"));
+        createSymbolicLink(sub.resolve("inner.txt"), Path.of("..", 
"hello.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceivedInAnyOrder("Hello World", "Hello World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME, 
"hello.txt", "sub/inner.txt");
+
+        consume("inbox", "&recursive=true");
+
+        mock.assertIsSatisfied();
+    }
+
+    private void consume(String directory, String query) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("mina-sftp://localhost:"; + service.getPort() + "/" + 
base.resolve(directory)
+                     + 
"?username=admin&password=admin&strictHostKeyChecking=no&useUserKnownHostsFile=false"
+                     + "&initialDelay=0&delay=100&noop=true" + query)
+                        .convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+    }
+
+    private static void createSymbolicLink(Path link, Path target) {
+        try {
+            Files.createSymbolicLink(link, target);
+        } catch (FileAlreadyExistsException e) {
+            throw new IllegalStateException(e);
+        } catch (UnsupportedOperationException | FileSystemException e) {
+            // such as Windows when the user may not create symbolic links
+            Assumptions.abort("Symbolic links cannot be created on this 
platform: " + e.getMessage());
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 50e86e06e8f3..b257ec4123cf 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -550,6 +550,15 @@ as soon as the encoding changed a byte, a body read with 
another charset (such a
 characters. The checksum is now calculated from the bytes written to the file, 
and the body is decoded as it is
 without `checksumFileAlgorithm`. Checksums of such files differ from the ones 
written before.
 
+=== camel-ftp, camel-mina-sftp - the SFTP consumers resolve listed symbolic 
links for jailStartingDirectory
+
+With `jailStartingDirectory` enabled (the default), the `sftp` and `mina-sftp` 
consumers now resolve a listed file that
+the server reports as a symbolic link, and skip it when its target is outside 
the starting directory or cannot be
+resolved (such as a dangling link), as the file consumer does. A link whose 
target contains a backslash (`\`) is
+skipped too. Before, such a link was consumed like any other file, wherever it 
pointed. A skipped file is logged at WARN
+the first time and at DEBUG on later polls. Links resolving inside
+the starting directory work as before. Set `jailStartingDirectory=false` to 
keep following links to other directories.
+
 === camel-pqc - an unknown hybridKdfAlgorithm is rejected
 
 The hybrid KEM operations now fail with an `IllegalArgumentException` when 
`hybridKdfAlgorithm` is not one of

Reply via email to