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);
