You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/08 19:35:39 UTC
(camel) branch camel-4.22.x updated: [backport camel-4.22.x] CAMEL-25412: camel-file - resolve symbolic links when enforcing jailStartingDirectory (#27575)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.22.x by this push:
new fcbba9029d86 [backport camel-4.22.x] CAMEL-25412: camel-file - resolve symbolic links when enforcing jailStartingDirectory (#27575)
fcbba9029d86 is described below
commit fcbba9029d86a68342af42864dfc49c355825db2
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Oct 8 21:35:31 2026 +0200
[backport camel-4.22.x] CAMEL-25412: camel-file - resolve symbolic links when enforcing jailStartingDirectory (#27575)
Backport of #27522.
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
.../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_22.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 5d2024332c7b..751456b58dac 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 5d2024332c7b..751456b58dac 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 3b4505720fcf..7d5cd0f30726 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 6bd74b8bdd61..2d08f76b0bc1 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 f42276871293..a06532ecd713 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) {
@@ -633,9 +637,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;
}
@@ -692,15 +702,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;
}
@@ -933,6 +943,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 278a93c88441..2f7b4dddaaf1 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
@@ -74,7 +74,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_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 8c2ad20d563e..85b495710d8f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -860,6 +860,16 @@ and `Files.copy()` instead of `FileChannel`-based streaming. The `forceWrites` f
The default value has been changed from `true` to `false` to reflect the actual behavior. The option will
be removed in a future release.
+=== 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-mail - MimeMultipartDataFormat inbound header filtering
When unmarshalling a MIME message with `headersInline=true`, the `mime-multipart` data format now applies a