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

davsclaus 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 1dcbe2999ebe CAMEL-25412: camel-file - resolve symbolic links when 
enforcing jailStartingDirectory (#27522)
1dcbe2999ebe is described below

commit 1dcbe2999ebeed745f9cc4c8aa39ecb602c24a12
Author: allthingssecurity <[email protected]>
AuthorDate: Thu Oct 8 18:40:20 2026 +0530

    CAMEL-25412: camel-file - resolve symbolic links when enforcing 
jailStartingDirectory (#27522)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../apache/camel/catalog/docs/file-component.adoc  |   9 +
 .../camel-file/src/main/docs/file-component.adoc   |   9 +
 .../apache/camel/component/file/FileConsumer.java  |  25 +++
 .../camel/component/file/FileOperations.java       |  32 ++++
 .../camel/component/file/GenericFileConsumer.java  |  23 ++-
 .../camel/component/file/GenericFileHelper.java    |  23 ++-
 .../camel/component/file/GenericFileProducer.java  |   5 +
 ...leConsumerJailStartingDirectorySymlinkTest.java | 187 +++++++++++++++++++++
 ...leProducerJailStartingDirectorySymlinkTest.java | 168 ++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  10 ++
 10 files changed, 484 insertions(+), 7 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
index 7d9b044aa6ac..8e7e55ca5ec5 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/file-component.adoc
@@ -714,6 +714,15 @@ good at detecting this. You may also want to look at the 
`doneFileName`
 option, which uses a marker file (_done file_) to signal when a file is
 done and ready to be consumed.
 
+=== Symbolic links and the starting directory
+
+With `jailStartingDirectory` enabled (the default), symbolic links are 
resolved when checking that a file stays within
+the starting directory. The consumer skips a file, or with `recursive=true` a 
directory, that is a link resolving
+outside the starting directory (logged at WARN the first time), and the 
producer fails to write through such a link.
+Links that resolve inside the starting directory, and a starting directory 
that is itself a link, work as usual. Set
+`jailStartingDirectory=false` if the application relies on following links to 
other directories. The check and the
+read or write that follows are not atomic, so the permissions of the 
directories still matter.
+
 === Done files
 
 ==== Using done files
diff --git a/components/camel-file/src/main/docs/file-component.adoc 
b/components/camel-file/src/main/docs/file-component.adoc
index 7d9b044aa6ac..8e7e55ca5ec5 100644
--- a/components/camel-file/src/main/docs/file-component.adoc
+++ b/components/camel-file/src/main/docs/file-component.adoc
@@ -714,6 +714,15 @@ good at detecting this. You may also want to look at the 
`doneFileName`
 option, which uses a marker file (_done file_) to signal when a file is
 done and ready to be consumed.
 
+=== Symbolic links and the starting directory
+
+With `jailStartingDirectory` enabled (the default), symbolic links are 
resolved when checking that a file stays within
+the starting directory. The consumer skips a file, or with `recursive=true` a 
directory, that is a link resolving
+outside the starting directory (logged at WARN the first time), and the 
producer fails to write through such a link.
+Links that resolve inside the starting directory, and a starting directory 
that is itself a link, work as usual. Set
+`jailStartingDirectory=false` if the application relies on following links to 
other directories. The check and the
+read or write that follows are not atomic, so the permissions of the 
directories still matter.
+
 === Done files
 
 ==== Using done files
diff --git 
a/components/camel-file/src/main/java/org/apache/camel/component/file/FileConsumer.java
 
b/components/camel-file/src/main/java/org/apache/camel/component/file/FileConsumer.java
index 866dee84955c..c58c83c76af6 100644
--- 
a/components/camel-file/src/main/java/org/apache/camel/component/file/FileConsumer.java
+++ 
b/components/camel-file/src/main/java/org/apache/camel/component/file/FileConsumer.java
@@ -54,6 +54,8 @@ public class FileConsumer extends GenericFileConsumer<File> 
implements ResumeAwa
     private ResumeStrategy resumeStrategy;
     private final String endpointPath;
     private Set<String> extendedAttributes;
+    // the starting directory with its symbolic links resolved, computed once 
per poll
+    private volatile Path resolvedStartingDirectory;
 
     public FileConsumer(FileEndpoint endpoint, Processor processor, 
GenericFileOperations<File> operations,
                         GenericFileProcessStrategy<File> processStrategy) {
@@ -75,6 +77,26 @@ public class FileConsumer extends GenericFileConsumer<File> 
implements ResumeAwa
         return exchange;
     }
 
+    @Override
+    protected boolean isWithinStartingDirectory(String absoluteFilePath) {
+        // a local listing entry is a single path segment, but it can be a 
symbolic link to a file or to a directory
+        // (entered with recursive) whose target lies outside the starting 
directory, so compare the resolved paths
+        // the starting directory is resolved once per poll, each listed file 
on every poll (one toRealPath per file)
+        try {
+            Path startingDirectory = resolvedStartingDirectory;
+            if (startingDirectory == null) {
+                startingDirectory = 
GenericFileHelper.resolveExistingPathSegments(getEndpoint().getFile().toPath());
+                resolvedStartingDirectory = startingDirectory;
+            }
+            return 
GenericFileHelper.resolveExistingPathSegments(Path.of(absoluteFilePath)).startsWith(startingDirectory);
+        } catch (IOException e) {
+            // such as a dangling symbolic link
+            LOG.debug("Cannot resolve file: {} against the starting directory 
due to: {}", absoluteFilePath,
+                    e.getMessage());
+            return false;
+        }
+    }
+
     private boolean pollDirectory(Exchange dynamic, File directory, 
List<GenericFile<File>> fileList, int depth) {
         depth++;
 
@@ -201,6 +223,9 @@ public class FileConsumer extends GenericFileConsumer<File> 
implements ResumeAwa
     protected boolean pollDirectory(Exchange dynamic, String fileName, 
List<GenericFile<File>> fileList, int depth) {
         LOG.trace("pollDirectory from fileName: {}", fileName);
 
+        // resolved again on the first check of this poll, as the starting 
directory can be replaced between polls
+        resolvedStartingDirectory = null;
+
         File directory = new File(fileName);
         if (!directory.exists() || !directory.isDirectory()) {
             LOG.debug("Cannot poll as directory does not exist or its not a 
directory: {}", directory);
diff --git 
a/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
 
b/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
index ba6aabec6e9c..24261c8c5d18 100644
--- 
a/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
+++ 
b/components/camel-file/src/main/java/org/apache/camel/component/file/FileOperations.java
@@ -30,6 +30,7 @@ import java.nio.channels.FileChannel;
 import java.nio.channels.SeekableByteChannel;
 import java.nio.charset.Charset;
 import java.nio.file.Files;
+import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
 import java.nio.file.StandardOpenOption;
 import java.nio.file.attribute.PosixFilePermission;
@@ -256,11 +257,41 @@ public class FileOperations implements 
GenericFileOperations<File> {
         // java.io.File
     }
 
+    /**
+     * The jailStartingDirectory check of the producer is lexical (see {@code 
GenericFileProducer.createFileName}), so a
+     * symbolic link inside the starting directory (a link to a file, or a 
directory segment of the name) would redirect
+     * the write outside of it. Only paths that are lexically inside the 
starting directory are checked, so a
+     * tempFileName the route author placed outside of it (such as ../work) 
keeps working. The producer also calls this
+     * before it creates missing parent directories of the target.
+     */
+    void jailToStartingDirectory(File target) {
+        if (!endpoint.isJailStartingDirectory()) {
+            return;
+        }
+        Path startingDirectory = endpoint.getFile().toPath();
+        if 
(!target.toPath().toAbsolutePath().normalize().startsWith(startingDirectory.toAbsolutePath().normalize()))
 {
+            return;
+        }
+        boolean within;
+        try {
+            within = 
GenericFileHelper.isWithinDirectoryResolvingLinks(target.toPath(), 
startingDirectory);
+        } catch (IOException e) {
+            throw new GenericFileOperationFailedException(
+                    "Cannot verify file: " + target + " is within the starting 
directory: " + endpoint.getFile(), e);
+        }
+        if (!within) {
+            throw new GenericFileOperationFailedException(
+                    "Cannot write file: " + target + " as it resolves outside 
the starting directory: "
+                                                          + 
endpoint.getFile());
+        }
+    }
+
     @Override
     public boolean storeFile(String fileName, Exchange exchange, long size) 
throws GenericFileOperationFailedException {
         ObjectHelper.notNull(endpoint, "endpoint");
 
         File file = new File(fileName);
+        jailToStartingDirectory(file);
 
         // if an existing file already exists what should we do?
         if (file.exists()) {
@@ -355,6 +386,7 @@ public class FileOperations implements 
GenericFileOperations<File> {
     @Override
     public boolean storeFileDirectly(String name, String payload) throws 
GenericFileOperationFailedException {
         File file = new File(name);
+        jailToStartingDirectory(file);
         try {
             Files.writeString(file.toPath(), payload, 
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING,
                     StandardOpenOption.CREATE);
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 6a6039e0b85b..493ad6067234 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
@@ -22,6 +22,7 @@ import java.util.Collections;
 import java.util.Deque;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.Map;
 import java.util.Queue;
 import java.util.function.Supplier;
 import java.util.regex.Pattern;
@@ -34,6 +35,7 @@ import org.apache.camel.Processor;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.ShutdownRunningTask;
 import org.apache.camel.support.EmptyAsyncCallback;
+import org.apache.camel.support.LRUCacheFactory;
 import org.apache.camel.support.ScheduledBatchPollingConsumer;
 import org.apache.camel.support.service.ServiceHelper;
 import org.apache.camel.util.CastUtils;
@@ -63,6 +65,8 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
     private final String[] includeExt;
     private final String[] excludeExt;
     private boolean retrieveFile = true;
+    // files already reported as resolving outside the starting directory 
(bounded)
+    private final Map<String, Boolean> skippedOutsideStartingDirectory = 
LRUCacheFactory.newLRUCache(1000);
 
     protected GenericFileConsumer(GenericFileEndpoint<T> endpoint, Processor 
processor, GenericFileOperations<T> operations,
                                   GenericFileProcessStrategy<T> 
processStrategy) {
@@ -700,9 +704,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
+        // 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)) {
-            LOG.warn("Skipping file as it resolves outside the starting 
directory: {}", absoluteFilePath);
+            // 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);
+            } else {
+                LOG.debug("Skipping file as it resolves outside the starting 
directory: {}", absoluteFilePath);
+            }
             return false;
         }
 
@@ -759,15 +769,15 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
      * 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.
+     * guaranteed to be a single path segment and can otherwise navigate 
outside the directory being polled. The local
+     * file consumer overrides this to resolve symbolic links. 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
+        // no boundary to enforce by default
         return true;
     }
 
@@ -1000,6 +1010,7 @@ public abstract class GenericFileConsumer<T> extends 
ScheduledBatchPollingConsum
     @Override
     protected void doStop() throws Exception {
         prepareOnStartup = false;
+        skippedOutsideStartingDirectory.clear();
         super.doStop();
         ServiceHelper.stopService(processStrategy);
     }
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 3e2c4c81b862..116c7b42b248 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
@@ -81,7 +81,28 @@ public final class GenericFileHelper {
         }
     }
 
-    private static Path resolveExistingPathSegments(Path path) throws 
IOException {
+    /**
+     * Determines whether a local path stays within a local directory once the 
existing segments of both are resolved on
+     * the filesystem, following symbolic links. Unlike the lexical {@link 
#isWithinDirectory(String, String)} this sees
+     * a symbolic link placed inside the directory whose target lies outside 
of it. The check and a later use of the
+     * path are not atomic.
+     *
+     * @param  target      the local path (need not exist yet)
+     * @param  directory   the directory the path must stay within
+     * @return             {@code true} if the resolved path is the directory 
itself or a path inside it
+     * @throws IOException if the path cannot be resolved, for example a 
dangling symbolic link
+     */
+    public static boolean isWithinDirectoryResolvingLinks(Path target, Path 
directory) throws IOException {
+        return 
resolveExistingPathSegments(target).startsWith(resolveExistingPathSegments(directory));
+    }
+
+    /**
+     * Resolves the longest existing prefix of the path on the filesystem 
(following symbolic links) and appends the
+     * remaining, not yet existing, segments.
+     *
+     * @throws IOException if the path cannot be resolved, for example a 
dangling symbolic link
+     */
+    static Path resolveExistingPathSegments(Path path) throws IOException {
         // Preserve the raw path segments here. Normalizing before resolving 
links changes the filesystem meaning of
         // paths such as link/../file when link points to another directory.
         final Path absolutePath = path.toAbsolutePath();
diff --git 
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
 
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
index 03932fb9824d..30bac75a664f 100644
--- 
a/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
+++ 
b/components/camel-file/src/main/java/org/apache/camel/component/file/GenericFileProducer.java
@@ -335,6 +335,11 @@ public class GenericFileProducer<T> extends 
DefaultAsyncProducer {
             String directory = file.getParent();
             boolean absolute = FileUtil.isAbsolute(file);
             if (directory != null) {
+                if (operations instanceof FileOperations fileOperations) {
+                    // check before the missing directories are created, as 
they could otherwise be created through a
+                    // symbolic link outside the starting directory 
(buildDirectory itself is also used by consumer moves)
+                    fileOperations.jailToStartingDirectory(file);
+                }
                 if (!operations.buildDirectory(directory, absolute)) {
                     LOG.debug("Cannot build directory [{}] (could be because 
of denied permissions)", directory);
                 }
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumerJailStartingDirectorySymlinkTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumerJailStartingDirectorySymlinkTest.java
new file mode 100644
index 000000000000..f30c301abac8
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileConsumerJailStartingDirectorySymlinkTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileSystemException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.log.ConsumingAppender;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * With jailStartingDirectory (the default) the file consumer must not pick up 
a file through a symbolic link that lives
+ * inside the starting directory and resolves outside of it.
+ */
+class FileConsumerJailStartingDirectorySymlinkTest extends ContextTestSupport {
+
+    private static final String CONSUMER_LOGGER = 
GenericFileConsumer.class.getName();
+
+    private final Queue<Level> skippedLinkLevels = new 
ConcurrentLinkedQueue<>();
+    private Path inbox;
+    private Path outside;
+
+    @Override
+    public boolean isUseRouteBuilder() {
+        return false;
+    }
+
+    @BeforeEach
+    void createDirectories() throws IOException {
+        inbox = Files.createDirectories(testDirectory("inbox"));
+        outside = Files.createDirectories(testDirectory("outside"));
+        Files.writeString(outside.resolve("secret.txt"), "Outside");
+    }
+
+    @AfterEach
+    void removeAppender() {
+        LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+        ctx.getConfiguration().removeLogger(CONSUMER_LOGGER);
+        ctx.updateLoggers();
+    }
+
+    @Test
+    void fileLinkToOutsideIsSkippedAndReportedOnce() throws Exception {
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Path link = inbox.resolve("link.txt");
+        createSymbolicLink(link, outside.resolve("secret.txt"));
+        ConsumingAppender.newAppender(CONSUMER_LOGGER, 
"FileConsumerJailStartingDirectorySymlinkTest", Level.DEBUG,
+                event -> {
+                    String message = event.getMessage().getFormattedMessage();
+                    if (message.startsWith("Skipping file as it resolves 
outside the starting directory")
+                            && message.endsWith(link.toString())) {
+                        skippedLinkLevels.add(event.getLevel());
+                    }
+                });
+
+        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("?initialDelay=0&delay=10&noop=true");
+
+        mock.assertIsSatisfied();
+        // the link stays in place and is listed by every poll, but is 
reported at WARN only once
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
skippedLinkLevels.size() >= 3);
+        assertEquals(1, 
skippedLinkLevels.stream().filter(Level.WARN::equals).count(), 
skippedLinkLevels.toString());
+    }
+
+    @Test
+    void directoryLinkToOutsideIsNotEnteredWhenRecursive() throws Exception {
+        Files.createDirectories(inbox.resolve("sub"));
+        Files.writeString(inbox.resolve("sub/hello.txt"), "Hello World");
+        createSymbolicLink(inbox.resolve("linkdir"), outside);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+        mock.message(0).exchangeProperty(Exchange.BATCH_SIZE).isEqualTo(1);
+
+        consume("?initialDelay=0&delay=10&noop=true&recursive=true");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void linksInsideStartingDirectoryAreConsumed() throws Exception {
+        Path data = Files.createDirectories(inbox.resolve("data"));
+        Path hello = Files.writeString(data.resolve("hello.txt"), "Hello 
World");
+        createSymbolicLink(inbox.resolve("current"), data);
+        createSymbolicLink(inbox.resolve("latest.txt"), hello);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World", "Hello World", "Hello 
World");
+        mock.expectedHeaderValuesReceivedInAnyOrder(Exchange.FILE_NAME,
+                "current" + File.separator + "hello.txt", "data" + 
File.separator + "hello.txt", "latest.txt");
+
+        consume("?initialDelay=0&delay=10&noop=true&recursive=true");
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void startingDirectoryReachedThroughLinkIsConsumed() throws Exception {
+        Files.writeString(inbox.resolve("hello.txt"), "Hello World");
+        Path inboxLink = testDirectory("inbox-link");
+        createSymbolicLink(inboxLink, inbox);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Hello World");
+
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(fileUri(inboxLink, 
"?initialDelay=0&delay=10&noop=true")).convertBodyTo(String.class)
+                        .to("mock:result");
+            }
+        });
+        context.start();
+
+        mock.assertIsSatisfied();
+    }
+
+    @Test
+    void fileLinkToOutsideIsConsumedWhenJailStartingDirectoryIsDisabled() 
throws Exception {
+        createSymbolicLink(inbox.resolve("link.txt"), 
outside.resolve("secret.txt"));
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("Outside");
+
+        
consume("?initialDelay=0&delay=10&noop=true&jailStartingDirectory=false");
+
+        mock.assertIsSatisfied();
+    }
+
+    private void consume(String query) throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                from(fileUri(inbox, 
query)).convertBodyTo(String.class).to("mock:result");
+            }
+        });
+        context.start();
+    }
+
+    private static void createSymbolicLink(Path link, Path target) {
+        try {
+            Files.createSymbolicLink(link, target);
+        } catch (UnsupportedOperationException | FileSystemException e) {
+            // such as Windows without the privilege to 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/core/camel-core/src/test/java/org/apache/camel/component/file/FileProducerJailStartingDirectorySymlinkTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileProducerJailStartingDirectorySymlinkTest.java
new file mode 100644
index 000000000000..c2b82aa9f7d0
--- /dev/null
+++ 
b/core/camel-core/src/test/java/org/apache/camel/component/file/FileProducerJailStartingDirectorySymlinkTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.nio.file.FileSystemException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeEach;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * With jailStartingDirectory (the default) the file producer must not write 
outside the starting directory through a
+ * symbolic link that lives inside it.
+ */
+class FileProducerJailStartingDirectorySymlinkTest extends ContextTestSupport {
+
+    private Path out;
+    private Path elsewhere;
+
+    @BeforeEach
+    void createDirectories() throws IOException {
+        out = Files.createDirectories(testDirectory("out"));
+        elsewhere = Files.createDirectories(testDirectory("elsewhere"));
+    }
+
+    @Test
+    void directoryLinkToOutsideIsRejected() {
+        createSymbolicLink(out.resolve("link"), elsewhere);
+
+        assertRejected(fileUri(out, ""), "link/hello.txt");
+
+        assertFalse(Files.exists(elsewhere.resolve("hello.txt")));
+    }
+
+    @Test
+    void missingDirectoryBelowDirectoryLinkToOutsideIsNotCreated() {
+        // autoCreate (the default) creates the missing parent directories 
before the file is written
+        createSymbolicLink(out.resolve("link"), elsewhere);
+
+        assertRejected(fileUri(out, ""), "link/new/hello.txt");
+
+        assertFalse(Files.exists(elsewhere.resolve("new")));
+    }
+
+    @Test
+    void parentSegmentAfterDirectoryLinkIsRejected() {
+        // lexically out/hello.txt, but the filesystem resolves link/.. to the 
parent of the link target
+        createSymbolicLink(out.resolve("link"), elsewhere);
+
+        assertRejected(fileUri(out, ""), "link/../hello.txt");
+
+        assertFalse(Files.exists(testDirectory("hello.txt")));
+    }
+
+    @Test
+    void fileLinkToOutsideIsRejected() throws Exception {
+        Path target = Files.writeString(elsewhere.resolve("target.txt"), 
"Original");
+        createSymbolicLink(out.resolve("hello.txt"), target);
+
+        assertRejected(fileUri(out, ""), "hello.txt");
+
+        assertEquals("Original", Files.readString(target));
+    }
+
+    @Test
+    void danglingLinkToOutsideIsRejected() {
+        Path target = elsewhere.resolve("missing.txt");
+        createSymbolicLink(out.resolve("hello.txt"), target);
+
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(fileUri(out, ""), "Hello 
World", Exchange.FILE_NAME, "hello.txt"));
+
+        GenericFileOperationFailedException cause
+                = 
assertIsInstanceOf(GenericFileOperationFailedException.class, e.getCause());
+        assertTrue(cause.getMessage().startsWith("Cannot verify file:"), 
cause.getMessage());
+        assertFalse(Files.exists(target));
+    }
+
+    @Test
+    void checksumFileLinkToOutsideIsRejected() throws Exception {
+        Path target = Files.writeString(elsewhere.resolve("target.txt"), 
"Original");
+        createSymbolicLink(out.resolve("hello.txt.md5"), target);
+
+        assertRejected(fileUri(out, "?checksumFileAlgorithm=md5"), 
"hello.txt");
+
+        assertEquals("Original", Files.readString(target));
+    }
+
+    @Test
+    void linksInsideStartingDirectoryAreFollowed() throws Exception {
+        Path archive = Files.createDirectories(out.resolve("archive"));
+        createSymbolicLink(out.resolve("current"), archive);
+        Path latest = Files.writeString(archive.resolve("latest.txt"), "Old");
+        createSymbolicLink(out.resolve("latest.txt"), latest);
+
+        template.sendBodyAndHeader(fileUri(out, ""), "Hello World", 
Exchange.FILE_NAME, "current/hello.txt");
+        template.sendBodyAndHeader(fileUri(out, ""), "New", 
Exchange.FILE_NAME, "latest.txt");
+
+        assertEquals("Hello World", 
Files.readString(archive.resolve("hello.txt")));
+        assertEquals("New", Files.readString(latest));
+        assertTrue(Files.isSymbolicLink(out.resolve("latest.txt")));
+    }
+
+    @Test
+    void parentDirectoryIsStillRejectedLexically() {
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(fileUri(out, ""), "Hello 
World", Exchange.FILE_NAME, "../hello.txt"));
+
+        IllegalArgumentException cause = 
assertIsInstanceOf(IllegalArgumentException.class, e.getCause());
+        assertTrue(cause.getMessage().contains("as the filename is jailed to 
the starting directory"), cause.getMessage());
+        assertFalse(Files.exists(testDirectory("hello.txt")));
+    }
+
+    @Test
+    void directoryLinkIsFollowedWhenJailStartingDirectoryIsDisabled() throws 
Exception {
+        createSymbolicLink(out.resolve("link"), elsewhere);
+
+        template.sendBodyAndHeader(fileUri(out, 
"?jailStartingDirectory=false"), "Hello World", Exchange.FILE_NAME,
+                "link/hello.txt");
+
+        assertEquals("Hello World", 
Files.readString(elsewhere.resolve("hello.txt")));
+    }
+
+    private void assertRejected(String uri, String fileName) {
+        CamelExecutionException e = assertThrows(CamelExecutionException.class,
+                () -> template.sendBodyAndHeader(uri, "Hello World", 
Exchange.FILE_NAME, fileName));
+
+        GenericFileOperationFailedException cause
+                = 
assertIsInstanceOf(GenericFileOperationFailedException.class, e.getCause());
+        assertTrue(cause.getMessage().contains("as it resolves outside the 
starting directory"), cause.getMessage());
+    }
+
+    private static void createSymbolicLink(Path link, Path target) {
+        try {
+            Files.createSymbolicLink(link, target);
+        } catch (UnsupportedOperationException | FileSystemException e) {
+            // such as Windows without the privilege to 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_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 4ed981d11a4f..823a0601ee78 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
@@ -3601,6 +3601,16 @@ Downloads to a configured `localWorkDirectory` now 
resolve existing filesystem p
 checking that the destination remains inside that directory. Downloads through 
a symbolic link that
 resolves outside the `localWorkDirectory` are rejected. Valid nested download 
paths continue to work.
 
+=== camel-file - jailStartingDirectory resolves symbolic links
+
+With `jailStartingDirectory` enabled (the default), the file consumer and 
producer now resolve symbolic links when
+checking that a file stays within the starting directory. The consumer skips a 
file or (with `recursive=true`) a
+directory that is a link resolving outside of it, and the producer fails to 
write through such a link or a dangling
+link. Links resolving inside the starting directory work as before. Set 
`jailStartingDirectory=false` to keep following
+links to other directories (this also turns off the check of `../` in producer 
file names). A skipped file is logged
+at WARN the first time and at DEBUG on later polls; this log-level change also 
applies to the remote file consumers,
+which keep their existing lexical check.
+
 === camel-file, camel-ftp, camel-smb, camel-mina-sftp and camel-azure-files - 
a file whose download fails is retried by idempotent consumers
 
 When the consumer cannot download a file (for example a FTP, SFTP or SMB 
transfer fails with a socket timeout or a

Reply via email to