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