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

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


The following commit(s) were added to refs/heads/master by this push:
     new 2b1951f  Prevent remote resource path traversal (#308)
2b1951f is described below

commit 2b1951f443f3423bc8e77a780df7f89817746680
Author: Efe <[email protected]>
AuthorDate: Wed Oct 28 15:49:49 2026 +0300

    Prevent remote resource path traversal (#308)
    
    * Fix remote resource path traversal
    
    Signed-off-by: Efe Gökdemir <[email protected]>
    
    * fix: clarify and cache remote resource paths
    
    * Use specific remote resource path errors
    
    * Use literals for path validation errors
    
    * fix: remove redundant output path overload
    
    * test: cover symlink resource path escape
    
    * Fix symlink escapes in remote resource output
    
    ---------
    
    Signed-off-by: Efe Gökdemir <[email protected]>
---
 .../remote/AbstractProcessRemoteResourcesMojo.java |  51 ++++++++-
 .../resources/remote/RemoteResourcesMojoTest.java  | 123 +++++++++++++++++++++
 2 files changed, 173 insertions(+), 1 deletion(-)

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 8367976..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;
@@ -957,6 +961,7 @@ public abstract class AbstractProcessRemoteResourcesMojo 
extends AbstractMojo {
 
         String velocityResource = null;
         try {
+            File canonicalOutputDirectory = outputDirectory.getCanonicalFile();
 
             for (Map.Entry<String, RemoteResourcesBundle> entry : 
remoteResources) {
                 String bundleResource = entry.getKey();
@@ -973,7 +978,7 @@ public abstract class AbstractProcessRemoteResourcesMojo 
extends AbstractMojo {
 
                 // Don't overwrite resource that are already being provided.
 
-                File outputFile = new File(outputDirectory, projectResource);
+                File outputFile = resolveOutputFile(outputDirectory, 
projectResource, canonicalOutputDirectory);
 
                 FileUtils.mkdir(outputFile.getParentFile().getAbsolutePath());
 
@@ -1031,6 +1036,50 @@ public abstract class AbstractProcessRemoteResourcesMojo 
extends AbstractMojo {
         }
     }
 
+    static File resolveOutputFile(File outputDirectory, String resourceName, 
File canonicalOutputDirectory)
+            throws IOException, MojoExecutionException {
+        if (new File(resourceName).isAbsolute()) {
+            throw new MojoExecutionException("Absolute remote resource paths 
are not allowed: " + resourceName);
+        }
+        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 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 {
         if (bundle.getRequiredProjectProperties() == null
                 || bundle.getRequiredProjectProperties().isEmpty()) {
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 78d895e..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
@@ -19,15 +19,19 @@
 package org.apache.maven.plugin.resources.remote;
 
 import java.io.File;
+import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 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;
@@ -52,6 +56,7 @@ import org.codehaus.plexus.util.IOUtil;
 import org.eclipse.aether.DefaultRepositorySystemSession;
 import org.eclipse.aether.internal.impl.SimpleLocalRepositoryManagerFactory;
 import org.eclipse.aether.repository.LocalRepository;
+import org.junit.Assume;
 
 /**
  * RemoteResources plugin Test Case
@@ -89,6 +94,124 @@ public class RemoteResourcesMojoTest extends 
AbstractMojoTestCase {
         mojo.execute();
     }
 
+    public void testRemoteResourceCannotEscapeOutputDirectory() throws 
Exception {
+        File outputDirectory = new File("target/remote-resource-output");
+        File canonicalOutputDirectory = outputDirectory.getCanonicalFile();
+
+        try {
+            AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                    outputDirectory, "../outside.txt", 
canonicalOutputDirectory);
+            fail("expected a resource outside the output directory to be 
rejected");
+        } catch (MojoExecutionException e) {
+            assertTrue(e.getMessage().contains("must remain within the output 
directory"));
+        }
+
+        try {
+            AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                    outputDirectory,
+                    new File(outputDirectory.getParentFile(), 
"absolute.txt").getAbsolutePath(),
+                    canonicalOutputDirectory);
+            fail("expected an absolute resource path outside the output 
directory to be rejected");
+        } catch (MojoExecutionException e) {
+            assertTrue(e.getMessage().contains("Absolute remote resource paths 
are not allowed"));
+        }
+
+        File outputFile = AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                outputDirectory, "nested/resource.txt", 
canonicalOutputDirectory);
+        assertEquals(new File(outputDirectory, 
"nested/resource.txt").getCanonicalFile(), outputFile);
+    }
+
+    public void testRemoteResourceCannotEscapeOutputDirectoryThroughSymlink() 
throws Exception {
+        Path temporaryDirectory = 
Files.createTempDirectory("remote-resource-path");
+        Path outputDirectory = 
Files.createDirectory(temporaryDirectory.resolve("output"));
+        Path outsideDirectory = 
Files.createDirectory(temporaryDirectory.resolve("outside"));
+        try {
+            Files.createSymbolicLink(outputDirectory.resolve("external"), 
outsideDirectory);
+        } catch (UnsupportedOperationException | IOException e) {
+            Assume.assumeNoException(e);
+        }
+
+        File output = outputDirectory.toFile();
+        File canonicalOutputDirectory = output.getCanonicalFile();
+        try {
+            AbstractProcessRemoteResourcesMojo.resolveOutputFile(
+                    output, "external/escaped.txt", canonicalOutputDirectory);
+            fail("expected a symlink target outside the output directory to be 
rejected");
+        } catch (MojoExecutionException e) {
+            assertTrue(e.getMessage().contains("must remain within the output 
directory"));
+        }
+    }
+
+    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