Handle root output directory when extracting layers

Use canonical Path containment checks so layer directories under the
filesystem root are accepted while entries and layer names that escape
the output directory remain rejected.

See gh-50501

Signed-off-by: Dongliang Xie <dragonfsky@gmail.com>
This commit is contained in:
Dongliang Xie
2026-05-27 10:31:32 +02:00
committed by Moritz Halbritter
parent bfd1b8c096
commit f7343398d4
2 changed files with 47 additions and 6 deletions
@@ -26,6 +26,7 @@ import java.io.OutputStream;
import java.io.PrintStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
@@ -55,6 +56,7 @@ import org.springframework.util.StringUtils;
* The {@code 'extract'} tools command.
*
* @author Moritz Halbritter
* @author Dongliang Xie
*/
class ExtractCommand extends Command {
@@ -363,14 +365,18 @@ class ExtractCommand extends Command {
}
private static File assertFileIsContainedInDirectory(File directory, File file, String name) throws IOException {
String canonicalOutputPath = directory.getCanonicalPath() + File.separator;
String canonicalEntryPath = file.getCanonicalPath();
Assert.state(canonicalEntryPath.startsWith(canonicalOutputPath),
Path canonicalOutputPath = directory.getCanonicalFile().toPath();
Path canonicalEntryPath = file.getCanonicalFile().toPath();
Assert.state(isFileContainedInDirectory(canonicalOutputPath, canonicalEntryPath),
() -> "Entry '%s' would be written to '%s'. This is outside the output location of '%s'. Verify the contents of your archive."
.formatted(name, canonicalEntryPath, canonicalOutputPath));
return file;
}
private static boolean isFileContainedInDirectory(Path canonicalOutputPath, Path canonicalFilePath) {
return !canonicalFilePath.equals(canonicalOutputPath) && canonicalFilePath.startsWith(canonicalOutputPath);
}
@FunctionalInterface
private interface EntryNameTransformer {
@@ -515,9 +521,9 @@ class ExtractCommand extends Command {
}
private File assertLayerDirectoryLocation(File layerDirectory, String layerName) throws IOException {
String canonicalOutputPath = this.directory.getCanonicalPath() + File.separator;
String canonicalLayerPath = layerDirectory.getCanonicalPath();
Assert.state(canonicalLayerPath.startsWith(canonicalOutputPath),
Path canonicalOutputPath = this.directory.getCanonicalFile().toPath();
Path canonicalLayerPath = layerDirectory.getCanonicalFile().toPath();
Assert.state(isFileContainedInDirectory(canonicalOutputPath, canonicalLayerPath),
() -> "Layer '%s' would be written to '%s'. This is outside the output location of '%s'. Verify the contents of your archive."
.formatted(layerName, canonicalLayerPath, canonicalOutputPath));
return layerDirectory;
@@ -20,11 +20,13 @@ import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.jar.Manifest;
@@ -45,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
* Tests for {@link ExtractCommand}.
*
* @author Moritz Halbritter
* @author Dongliang Xie
*/
class ExtractCommandTests extends AbstractJarModeTests {
@@ -370,6 +373,38 @@ class ExtractCommandTests extends AbstractJarModeTests {
.doesNotContain("test/spring-boot-loader/org/springframework/boot/loader/launch/JarLauncher.class");
}
@Test
void extractWhenDestinationIsFileSystemRoot() throws IOException {
Path layerDirectory = ExtractCommandTests.this.tempDir.toPath()
.resolve("root-output")
.resolve("dependencies")
.toAbsolutePath()
.normalize();
Path outputRoot = layerDirectory.getRoot();
String layerName = outputRoot.relativize(layerDirectory).toString().replace(File.separatorChar, '/');
Layers layers = new Layers() {
@Override
public Iterator<String> iterator() {
return List.of(layerName).iterator();
}
@Override
public String getLayer(String entryName) {
return layerName;
}
@Override
public String getApplicationLayerName() {
return layerName;
}
};
runCommand((context) -> new ExtractCommand(context, layers), ExtractCommandTests.this.archive,
"--destination", outputRoot.toString(), "--force", "--launcher", "--layers");
assertThat(layerDirectory.resolve("BOOT-INF/lib/dependency-1.jar")).exists();
}
}
}