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

slachiewicz pushed a commit to branch agent/pr308-on-master
in repository 
https://gitbox.apache.org/repos/asf/maven-remote-resources-plugin.git

commit 9dd44817a00d5eb7e1b1b28d525965940b05359c
Author: Efe Gökdemir <[email protected]>
AuthorDate: Tue Oct 20 12:25:28 2026 +0300

    Fix symlink escapes in remote resource output
---
 .../remote/AbstractProcessRemoteResourcesMojo.java | 42 +++++++++++--
 .../resources/remote/RemoteResourcesMojoTest.java  | 72 ++++++++++++++++++++++
 2 files changed, 110 insertions(+), 4 deletions(-)

diff --git 
a/src/main/java/org/apache/maven/plugin/resources/remote/AbstractProcessRemoteResourcesMojo.java
 
b/src/main/java/org/apache/maven/plugin/resources/remote/AbstractProcessRemoteResourcesMojo.java
index 6897f59..0e830ff 100644
--- 
a/src/main/java/org/apache/maven/plugin/resources/remote/AbstractProcessRemoteResourcesMojo.java
+++ 
b/src/main/java/org/apache/maven/plugin/resources/remote/AbstractProcessRemoteResourcesMojo.java
@@ -32,6 +32,10 @@ import java.net.MalformedURLException;
 import java.net.URL;
 import java.nio.charset.Charset;
 import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
@@ -1037,13 +1041,43 @@ public abstract class 
AbstractProcessRemoteResourcesMojo extends AbstractMojo {
         if (new File(resourceName).isAbsolute()) {
             throw new MojoExecutionException("Absolute remote resource paths 
are not allowed: " + resourceName);
         }
-        File outputFile = new File(outputDirectory, 
resourceName).getCanonicalFile();
-        if (outputFile.equals(canonicalOutputDirectory)
-                || 
!outputFile.toPath().startsWith(canonicalOutputDirectory.toPath())) {
+        Path canonicalOutputPath = 
resolvePathThroughExistingAncestors(canonicalOutputDirectory.toPath());
+        Path outputPath =
+                
resolvePathThroughExistingAncestors(outputDirectory.toPath().resolve(resourceName));
+        if (outputPath.equals(canonicalOutputPath) || 
!outputPath.startsWith(canonicalOutputPath)) {
             throw new MojoExecutionException(
                     "Remote resource path must remain within the output 
directory: " + resourceName);
         }
-        return outputFile;
+        return outputPath.toFile();
+    }
+
+    private static Path resolvePathThroughExistingAncestors(Path path) throws 
IOException {
+        Path normalizedPath = path.toAbsolutePath().normalize();
+        Path existingAncestor = normalizedPath;
+        List<Path> missingPathElements = new ArrayList<>();
+        while (existingAncestor != null && 
!existsWithoutFollowingLinks(existingAncestor)) {
+            missingPathElements.add(existingAncestor.getFileName());
+            existingAncestor = existingAncestor.getParent();
+        }
+
+        if (existingAncestor == null) {
+            return normalizedPath;
+        }
+
+        Path resolvedPath = existingAncestor.toRealPath();
+        for (int i = missingPathElements.size() - 1; i >= 0; i--) {
+            resolvedPath = resolvedPath.resolve(missingPathElements.get(i));
+        }
+        return resolvedPath.normalize();
+    }
+
+    private static boolean existsWithoutFollowingLinks(Path path) throws 
IOException {
+        try {
+            Files.readAttributes(path, BasicFileAttributes.class, 
LinkOption.NOFOLLOW_LINKS);
+            return true;
+        } catch (NoSuchFileException e) {
+            return false;
+        }
     }
 
     private void verifyRequiredProperties(RemoteResourcesBundle bundle, URL 
url) throws MojoExecutionException {
diff --git 
a/src/test/java/org/apache/maven/plugin/resources/remote/RemoteResourcesMojoTest.java
 
b/src/test/java/org/apache/maven/plugin/resources/remote/RemoteResourcesMojoTest.java
index e4f5411..ee3f292 100644
--- 
a/src/test/java/org/apache/maven/plugin/resources/remote/RemoteResourcesMojoTest.java
+++ 
b/src/test/java/org/apache/maven/plugin/resources/remote/RemoteResourcesMojoTest.java
@@ -26,10 +26,12 @@ import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.jar.JarOutputStream;
 import java.util.zip.ZipEntry;
@@ -140,6 +142,76 @@ public class RemoteResourcesMojoTest extends 
AbstractMojoTestCase {
         }
     }
 
+    public void 
testRemoteResourceResolvesNestedSymlinksBeforeCheckingContainment() throws 
Exception {
+        Path temporaryDirectory = 
Files.createTempDirectory("remote-resource-nested-path");
+        Path outputDirectory = 
Files.createDirectory(temporaryDirectory.resolve("output"));
+        Path insideDirectory = 
Files.createDirectory(outputDirectory.resolve("inside"));
+        Path outsideDirectory = 
Files.createDirectory(temporaryDirectory.resolve("outside"));
+        try {
+            Files.createSymbolicLink(outputDirectory.resolve("inside-link"), 
insideDirectory);
+            Files.createSymbolicLink(insideDirectory.resolve("outside-link"), 
outsideDirectory);
+        } catch (UnsupportedOperationException | IOException e) {
+            Assume.assumeNoException(e);
+        }
+
+        File output = outputDirectory.toFile();
+        File canonicalOutputDirectory = output.getCanonicalFile();
+        File safeOutputFile = 
AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                output, "inside-link/not-created/resource.txt", 
canonicalOutputDirectory);
+        assertEquals(
+                
insideDirectory.toRealPath().resolve("not-created/resource.txt").toFile(), 
safeOutputFile);
+
+        try {
+            AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                    output, 
"inside-link/outside-link/not-created/resource.txt", canonicalOutputDirectory);
+            fail("expected a nested symlink target outside the output 
directory to be rejected");
+        } catch (MojoExecutionException e) {
+            assertTrue(e.getMessage().contains("must remain within the output 
directory"));
+        }
+    }
+
+    public void testRemoteResourceFailsClosedWhenPathCannotBeInspected() 
throws Exception {
+        Path temporaryDirectory = 
Files.createTempDirectory("remote-resource-inaccessible-path");
+        Path outputDirectory = 
Files.createDirectory(temporaryDirectory.resolve("output"));
+        Path restrictedDirectory = 
Files.createDirectory(outputDirectory.resolve("restricted"));
+        Path outsideDirectory = 
Files.createDirectory(temporaryDirectory.resolve("outside"));
+
+        EnumSet<PosixFilePermission> originalPermissions;
+        try {
+            originalPermissions = 
EnumSet.copyOf(Files.getPosixFilePermissions(restrictedDirectory));
+        } catch (UnsupportedOperationException e) {
+            Assume.assumeNoException(e);
+            return;
+        }
+
+        try {
+            Files.createSymbolicLink(restrictedDirectory.resolve("external"), 
outsideDirectory);
+        } catch (UnsupportedOperationException | IOException e) {
+            Assume.assumeNoException(e);
+            return;
+        }
+
+        try {
+            Files.setPosixFilePermissions(
+                    restrictedDirectory, 
EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
+        } catch (UnsupportedOperationException e) {
+            Assume.assumeNoException(e);
+            return;
+        }
+
+        try {
+            AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                    outputDirectory.toFile(),
+                    "restricted/external/escaped.txt",
+                    outputDirectory.toFile().getCanonicalFile());
+            fail("expected unreadable path components to be rejected");
+        } catch (IOException expected) {
+            // An inaccessible component must not be treated as a missing path 
element.
+        } finally {
+            Files.setPosixFilePermissions(restrictedDirectory, 
originalPermissions);
+        }
+    }
+
     public void testConfigureLocatorRequiresProjectFile() throws Exception {
         final MavenProjectResourcesStub project = 
createTestProject("default-null-pom");
         final ProcessRemoteResourcesMojo mojo = 
lookupProcessMojoWithDefaultSettings(project);

Reply via email to