This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.18.x by this push:
new a8e654cfb362 [backport camel-4.18.x] CAMEL-25412: camel-file - resolve
symbolic links when enforcing jailStartingDirectory (#27574)
a8e654cfb362 is described below
commit a8e654cfb36221985cc752caf612e29c8d98ac70
Author: Guillaume Nodet <[email protected]>
AuthorDate: Thu Oct 8 18:55:38 2026 +0200
[backport camel-4.18.x] CAMEL-25412: camel-file - resolve symbolic links
when enforcing jailStartingDirectory (#27574)
---
.../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_18.adoc | 10 ++
9 files changed, 475 insertions(+), 7 deletions(-)
diff --git a/components/camel-file/src/main/docs/file-component.adoc
b/components/camel-file/src/main/docs/file-component.adoc
index a7869b61f37e..be4bd9c8bc4d 100644
--- a/components/camel-file/src/main/docs/file-component.adoc
+++ b/components/camel-file/src/main/docs/file-component.adoc
@@ -345,6 +345,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 2554dcdbfd21..b3ae33f19bc9 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 a632579bc527..dc0b30dc4b0a 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 9f4367ef9274..5f1a120553d0 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) {
@@ -621,9 +625,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;
}
@@ -680,15 +690,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;
}
@@ -921,6 +931,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 ab50db46a208..45cdf61622be 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
@@ -332,6 +332,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_18.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index 11d62d937428..0f189294ba46 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
@@ -1802,3 +1802,13 @@ custom `headerFilterStrategy` is used as-is and is
unaffected.
Routes that relied on one of those headers reaching the wire must set it
through the endpoint
configuration or supply a `headerFilterStrategy` that permits it.
+
+=== 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.