This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch backport/27522-to-camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 55b2d5a31b57aac280221059947abadb5dc88270 Author: Guillaume Nodet <[email protected]> AuthorDate: Thu Oct 8 13:18:15 2026 +0000 [backport camel-4.18.x] CAMEL-25412: camel-file - resolve symbolic links when enforcing jailStartingDirectory --- .../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.
