Skip to content

Commit 82ac6f3

Browse files
committed
GH-11095: Prevent file writing outside output directory
- Throw exception if '..' is used within the canonical directory. - Also prevent the absolute directory paths. - Add tests to verify directory traversal prevention. - Removed the NOSONAR's from class - Utilize checkFile for file creation in: - FileWriteingMessageHandler - AbstractRemoteFileOutboundGateway - Add additional test for multiple file navigation - Add additional test for isAbsolute filepath - Apply changes from code review Rename checkFile to newFileInDirectoryIfValid - Move newFileInDirectoryIfValid to FileUtils Refactor file validation and clean up comments - Remove try-catch blocks since checked exceptions are removed. - Delete Sonar suppression comments to maintain clean code. - Remove informational comments from tests. Refactor file validation and add traversal tests - Simplify path resolution in FileUtils. - Throw InvalidPathException instead of MessagingException. - Add directory traversal tests to FileWritingMessageHandlerTests. - Add directory traversal tests to FtpTests. - AbstractRemoteFileOutboundGateway does not have tests so, FTPTests were used as a proxy - FTP tests showed where a space can be inserted in front of a directory and still be accepted. This was remediated by stripping blanks in front of the fileName - Update the traversal validation code to use the canonical calls to identify changes It handles the navigation for all character variations, where the homegrown only checked for `..`. newFileInDirectoryIfValid must return un altered File Any File returned (after inspection) must contain the original filename Refactor absolute path validation and tests - Allow absolute paths in file validation when a directory is provided. - Fix FileUtils to reject absolute paths only if directory is empty. - Revert resultFile creation to avoid unintended path validation. - Refactor synchronizer tests to use Mockito for simpler session mocking. - Update FTP and synchronizer tests to align with new validation rules. Reject absolute directory for file sync - Absolute directory in file sync is reject regardles if parent directory is present. Move return in FileUtils to outside of try block - Add NOSONAR flags back to code - Remove unnecessary afterPropertiesSet and setBeanFactory from test - Resolve checkstyle error Refactor file validation for empty directories - Resolve absolute paths properly when target directories are empty. - Remove leading whitespace stripping to maintain strict file paths. - Add tests to verify path traversal logic for empty local directories. resultFile in the handleRequestMessage must be validated - Security scanner recommended that resultFile be validated for traversal. - tempfile also needs to be validated because it can be used instead of result file in AIL, IGNORE, REPLACE, REPLACE_IF_MODIFIED cases. Refactor file validation and update tests Refactor FileUtils to validate paths by comparing canonical and absolute paths instead of checking if they start with the directory path. This simplifies the validation logic and removes the need for the ABSOLUTE_DIR constant. - Remove redundant tests from AbstractRemoteFileSynchronizerTests - Add OS-specific directory traversal tests These tests still fail because the filter routine considers absolute navigation a security issue - Update FileWritingMessageHandler to use standard File instantiation This is based on our discussion that we only needed to test one of the entries not both - Refactor helper methods to be static - Set java.io.tmpdir to tmp dir when on macOS Java tmpdir defaults to /var however on macOS this is a symlink to /private/var Thus any test that tests for improper traversal of directories or uses code that inspects for improper traversal will fail because of the symlink due to how java implements its canonical resolution - Atomically replace the destination file with the temporary file, ensuring data integrity by overwriting any existing version in a single, indivisible operation. Resolves Issue: There is a Time-of-Check to Time-of-Use race condition between the canonical path check in FileUtils.newFileInDirectoryIfValid and the directory creation/file writing. A local attacker can replace a valid directory with a symlink during this window, leading to an Arbitrary File Overwrite. Consolidate test temp dir config and update tests - Move Mac OS X test temp dir config to root to reduce duplication. - Split checkPathOnAbsolute FTP test for OS-specific validation. - Add comment in FileWritingMessageHandler to clarify path checks. Move java_tmp prop set for mac to configure method - Remove blank lines left over from previous commit Revert build.gradle to 7.1.x-internal - Move property set to common configuration section * Some code cleanup and Windows compatibility for tests Fixes: #11095
1 parent 5511780 commit 82ac6f3

8 files changed

Lines changed: 226 additions & 10 deletions

File tree

build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,11 @@ configure(javaProjects) { subproject ->
355355

356356
jvmArgs '-Xshare:off',
357357
'--enable-native-access=ALL-UNNAMED'
358+
359+
if (org.gradle.internal.os.OperatingSystem.current().isMacOsX()) {
360+
systemProperty 'java.io.tmpdir', '/private/tmp'
361+
}
362+
358363
}
359364

360365
checkstyle {

spring-integration-file/src/main/java/org/springframework/integration/file/FileWritingMessageHandler.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,9 @@ protected Object handleRequestMessage(Message<?> requestMessage) {
521521

522522
File destinationDirectoryToUse = evaluateDestinationDirectoryExpression(requestMessage);
523523

524-
File tempFile = new File(destinationDirectoryToUse, generatedFileName + this.temporaryFileSuffix);
524+
File tempFile = FileUtils.newFileInDirectoryIfValid(destinationDirectoryToUse,
525+
generatedFileName + this.temporaryFileSuffix);
526+
// Validation above prevents Path Traversal for the line below; no redundant check needed.
525527
File resultFile = new File(destinationDirectoryToUse, generatedFileName);
526528

527529
boolean exists = resultFile.exists();

spring-integration-file/src/main/java/org/springframework/integration/file/remote/gateway/AbstractRemoteFileOutboundGateway.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.springframework.integration.file.remote.session.Session;
5252
import org.springframework.integration.file.remote.session.SessionFactory;
5353
import org.springframework.integration.file.support.FileExistsMode;
54+
import org.springframework.integration.file.support.FileUtils;
5455
import org.springframework.integration.handler.AbstractReplyProducingMessageHandler;
5556
import org.springframework.integration.handler.ExpressionEvaluatingMessageProcessor;
5657
import org.springframework.integration.handler.MessageProcessor;
@@ -1163,8 +1164,9 @@ protected File get(Message<?> message, Session<F> session, String remoteDir, //
11631164
}
11641165
fileInfo = files[0];
11651166
}
1166-
final File localFile =
1167-
new File(generateLocalDirectory(message, remoteDir), generateLocalFileName(message, remoteFilename));
1167+
File localFile =
1168+
FileUtils.newFileInDirectoryIfValid(generateLocalDirectory(message, remoteDir),
1169+
generateLocalFileName(message, remoteFilename));
11681170
FileExistsMode existsMode = resolveFileExistsMode(message);
11691171
boolean appending = FileExistsMode.APPEND.equals(existsMode);
11701172
boolean exists = localFile.exists();

spring-integration-file/src/main/java/org/springframework/integration/file/remote/synchronizer/AbstractInboundFileSynchronizer.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import java.io.OutputStream;
2525
import java.net.URI;
2626
import java.net.URISyntaxException;
27+
import java.nio.file.Files;
28+
import java.nio.file.StandardCopyOption;
2729
import java.util.Arrays;
2830
import java.util.Comparator;
2931
import java.util.List;
@@ -75,6 +77,7 @@
7577
* @author Gary Russell
7678
* @author Artem Bilan
7779
* @author Ngoc Nhan
80+
* @author Glenn Renfro
7881
*
7982
* @since 2.0
8083
*/
@@ -461,7 +464,7 @@ protected boolean copyFileToLocalDirectory(@Nullable String remoteDirectoryPath,
461464

462465
long modified = getModified(remoteFile);
463466

464-
File localFile = new File(localDirectory, localFileName);
467+
File localFile = FileUtils.newFileInDirectoryIfValid(localDirectory, localFileName);
465468
boolean exists = localFile.exists();
466469
if (!exists || (this.preserveTimestamp && modified != localFile.lastModified())) {
467470
if (!exists &&
@@ -556,11 +559,25 @@ private boolean copyRemoteContentToLocalFile(Session<F> session, String remoteFi
556559
+ "' from the remote to the local directory", e);
557560
}
558561

559-
renamed = tempFile.renameTo(localFile);
562+
try {
563+
Files.move(tempFile.toPath(), localFile.toPath(), StandardCopyOption.ATOMIC_MOVE,
564+
StandardCopyOption.REPLACE_EXISTING);
565+
renamed = true;
566+
}
567+
catch (IOException e) {
568+
renamed = false;
569+
}
560570

561571
if (!renamed) {
562572
if (localFile.delete()) {
563-
renamed = tempFile.renameTo(localFile);
573+
try {
574+
Files.move(tempFile.toPath(), localFile.toPath(), StandardCopyOption.ATOMIC_MOVE,
575+
StandardCopyOption.REPLACE_EXISTING);
576+
renamed = true;
577+
}
578+
catch (IOException e) {
579+
renamed = false;
580+
}
564581
if (!renamed && this.logger.isInfoEnabled()) {
565582
this.logger.info("Cannot rename '"
566583
+ tempFileName

spring-integration-file/src/main/java/org/springframework/integration/file/support/FileUtils.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616

1717
package org.springframework.integration.file.support;
1818

19+
import java.io.File;
20+
import java.io.IOException;
21+
import java.io.UncheckedIOException;
1922
import java.lang.reflect.Array;
2023
import java.nio.file.FileSystems;
24+
import java.nio.file.InvalidPathException;
2125
import java.util.Arrays;
2226
import java.util.Comparator;
2327
import java.util.function.Predicate;
@@ -69,6 +73,37 @@ public static <F> F[] purgeUnwantedElements(F[] fileArray, Predicate<? extends F
6973
}
7074
}
7175

76+
/**
77+
* Create a new file instance representing the specified file within the given base directory,
78+
* ensuring that the resulting file remains within the intended target directory.
79+
* Prevent Path Traversal vulnerabilities by explicitly rejecting file names that are absolute
80+
* paths or that resolve to canonical paths outside the provided base directory.
81+
* @param directory The base directory where the file is intended to be placed.
82+
* @param fileName The name of the file or the relative path to be resolved against the base directory.
83+
* @return A {@link File} object representing the securely resolved destination path.
84+
* @throws InvalidPathException If the {@code fileName} is an absolute path or attempts to traverse
85+
* outside the {@code directory}.
86+
* @throws UncheckedIOException If an I/O error occurs while resolving the canonical paths.
87+
* @since 5.5.21
88+
*/
89+
public static File newFileInDirectoryIfValid(File directory, String fileName) {
90+
if (new File(fileName).isAbsolute()) {
91+
throw new InvalidPathException(fileName,
92+
"The file is trying to leave the target output directory of " + directory);
93+
}
94+
try {
95+
File file = new File(directory.getCanonicalFile(), fileName);
96+
if (!file.getCanonicalPath().equals(file.getAbsolutePath())) {
97+
throw new InvalidPathException(fileName,
98+
"The file is trying to leave the target output directory of " + directory);
99+
}
100+
return file;
101+
}
102+
catch (IOException ex) {
103+
throw new UncheckedIOException(ex);
104+
}
105+
}
106+
72107
private FileUtils() {
73108
}
74109

spring-integration-file/src/test/java/org/springframework/integration/file/FileWritingMessageHandlerTests.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.io.IOException;
2525
import java.io.InputStream;
2626
import java.nio.file.Files;
27+
import java.nio.file.InvalidPathException;
2728
import java.nio.file.attribute.PosixFilePermission;
2829
import java.util.Map;
2930
import java.util.Set;
@@ -53,6 +54,7 @@
5354
import org.springframework.integration.test.util.TestUtils;
5455
import org.springframework.messaging.Message;
5556
import org.springframework.messaging.MessageHandlingException;
57+
import org.springframework.messaging.MessagingException;
5658
import org.springframework.messaging.support.GenericMessage;
5759
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
5860
import org.springframework.util.FileCopyUtils;
@@ -691,6 +693,19 @@ public void newFileCallback() throws Exception {
691693
assertFileContentIs(result, "foo" + System.lineSeparator() + "barbar");
692694
}
693695

696+
@Test
697+
public void checkPathThrowsOnDirectoryTraversal() {
698+
Message<?> message = MessageBuilder.withPayload("testdata").build();
699+
DefaultFileNameGenerator fileNameGenerator = new DefaultFileNameGenerator();
700+
fileNameGenerator.setExpression("'base/../../../escaped.txt'");
701+
handler.setFileNameGenerator(fileNameGenerator);
702+
703+
assertThatExceptionOfType(MessagingException.class)
704+
.isThrownBy(() -> handler.handleMessage(message))
705+
.withStackTraceContaining("trying to leave the target output directory")
706+
.withRootCauseInstanceOf(InvalidPathException.class);
707+
}
708+
694709
void assertFileContentIsMatching(Message<?> result) throws IOException {
695710
assertFileContentIs(result, SAMPLE_CONTENT);
696711
}

spring-integration-file/src/test/java/org/springframework/integration/file/remote/synchronizer/AbstractRemoteFileSynchronizerTests.java

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.io.IOException;
2121
import java.io.InputStream;
2222
import java.io.OutputStream;
23+
import java.nio.file.InvalidPathException;
2324
import java.util.LinkedList;
2425
import java.util.Queue;
2526
import java.util.UUID;
@@ -29,6 +30,8 @@
2930
import java.util.stream.Stream;
3031

3132
import org.junit.jupiter.api.Test;
33+
import org.junit.jupiter.api.condition.EnabledOnOs;
34+
import org.junit.jupiter.api.condition.OS;
3235
import org.junit.jupiter.api.io.TempDir;
3336

3437
import org.springframework.beans.factory.BeanFactory;
@@ -44,6 +47,7 @@
4447
import static org.assertj.core.api.Assertions.assertThat;
4548
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
4649
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
50+
import static org.mockito.BDDMockito.given;
4751
import static org.mockito.Mockito.mock;
4852

4953
/**
@@ -273,11 +277,93 @@ protected String protocol() {
273277
assertThat(localDir.list()).contains("dir1", "dir2");
274278
}
275279

276-
private AbstractInboundFileSynchronizingMessageSource<String> createSource(AtomicInteger count) {
280+
@Test
281+
public void checkPathThrowsOnDirectoryTraversal(@TempDir File localDir) {
282+
verifyDirectoryNotTheSame(localDir, "../base/escaped.txt");
283+
}
284+
285+
@Test
286+
public void checkPathOnDirectoryMidLineTraversal(@TempDir File localDir) {
287+
verifyDirectoryNotTheSame(localDir, "base/../escaped.txt");
288+
}
289+
290+
private static AbstractInboundFileSynchronizer<String> getAbstractInboundFileSynchronizer(String fileName) {
291+
StringSession session = mock(StringSession.class);
292+
given(session.list("remoteDir")).willReturn(new String[] {fileName});
293+
given(session.getHostPort()).willReturn("mock:1234");
294+
295+
AbstractInboundFileSynchronizer<String> sync = createSynchronizerWithSession(session);
296+
sync.setRemoteDirectory("remoteDir");
297+
return sync;
298+
}
299+
300+
@Test
301+
public void checkPathThrowsOnDirectoryExtendMidLineTraversal(@TempDir File localDir) {
302+
verifyDirectoryNotTheSame(localDir, "base/subdir/../../../../escaped.txt");
303+
}
304+
305+
@Test
306+
@EnabledOnOs(OS.WINDOWS)
307+
void checkPathThrowsOnAbsoluteDirectoryTraversalLinuxMacWindows(@TempDir File localDir) {
308+
verifyDirectoryNotTheSame(localDir, "C:\\projects\\data\\file.txt");
309+
}
310+
311+
@Test
312+
@EnabledOnOs(OS.WINDOWS)
313+
void notTraversalOnCurrentDirWindows() {
314+
AbstractInboundFileSynchronizer<String> sync = getAbstractInboundFileSynchronizer("build\\tmp\\file.txt");
315+
316+
sync.synchronizeToLocalDirectory(new File(""));
317+
318+
assertThat(new File("build\\tmp\\file.txt")).exists();
319+
}
320+
321+
@Test
322+
@EnabledOnOs({OS.LINUX, OS.MAC})
323+
void checkPathThrowsOnAbsoluteDirectoryTraversalLinuxMac(@TempDir File localDir) {
324+
verifyDirectoryNotTheSame(localDir, "/base/escaped.txt");
325+
}
326+
327+
private static void verifyDirectoryNotTheSame(File localDir, String fileName) {
328+
AbstractInboundFileSynchronizer<String> sync = getAbstractInboundFileSynchronizer(fileName);
329+
330+
assertThatExceptionOfType(MessagingException.class)
331+
.isThrownBy(() -> sync.synchronizeToLocalDirectory(localDir))
332+
.withStackTraceContaining("trying to leave the target output directory")
333+
.withRootCauseInstanceOf(InvalidPathException.class);
334+
}
335+
336+
private static AbstractInboundFileSynchronizer<String> createSynchronizerWithSession(StringSession session) {
337+
return new AbstractInboundFileSynchronizer<>(() -> session) {
338+
339+
@Override
340+
protected boolean isFile(String file) {
341+
return true;
342+
}
343+
344+
@Override
345+
protected String getFilename(String file) {
346+
return file;
347+
}
348+
349+
@Override
350+
protected long getModified(String file) {
351+
return 0;
352+
}
353+
354+
@Override
355+
protected String protocol() {
356+
return "mock";
357+
}
358+
359+
};
360+
}
361+
362+
private static AbstractInboundFileSynchronizingMessageSource<String> createSource(AtomicInteger count) {
277363
return createSource(createLimitingSynchronizer(count));
278364
}
279365

280-
private AbstractInboundFileSynchronizingMessageSource<String> createSource(
366+
private static AbstractInboundFileSynchronizingMessageSource<String> createSource(
281367
AbstractInboundFileSynchronizer<String> sync) {
282368

283369
AbstractInboundFileSynchronizingMessageSource<String> source =
@@ -297,7 +383,7 @@ public String getComponentType() {
297383
return source;
298384
}
299385

300-
private AbstractInboundFileSynchronizer<String> createLimitingSynchronizer(final AtomicInteger count) {
386+
private static AbstractInboundFileSynchronizer<String> createLimitingSynchronizer(final AtomicInteger count) {
301387
SessionFactory<String> sf = new StringSessionFactory();
302388
AbstractInboundFileSynchronizer<String> sync = new AbstractInboundFileSynchronizer<>(sf) {
303389

@@ -423,7 +509,7 @@ public Object getClientInstance() {
423509

424510
@Override
425511
public String getHostPort() {
426-
return "mock:6666";
512+
return "mock:1234";
427513
}
428514

429515
}

0 commit comments

Comments
 (0)