Skip to content

Commit 0046a44

Browse files
committed
Protect against corrupt buildpack archives
Update zip and tar handling in buildpack code to ensure that archive entries cannot be written outside of the expected destination. Although we consider buildpacks to be trusted, this update will help protect against corrupt archives. Closes gh-50141
1 parent aa5089e commit 0046a44

7 files changed

Lines changed: 93 additions & 31 deletions

File tree

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/ImageBuildpack.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.springframework.boot.buildpack.platform.docker.type.LayerId;
3838
import org.springframework.boot.buildpack.platform.io.IOConsumer;
3939
import org.springframework.boot.buildpack.platform.io.TarArchive;
40+
import org.springframework.util.Assert;
4041
import org.springframework.util.StreamUtils;
4142

4243
/**
@@ -132,6 +133,10 @@ private Path createLayerFile(TarArchive tarArchive) throws IOException {
132133
out.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
133134
TarArchiveEntry entry = in.getNextEntry();
134135
while (entry != null) {
136+
String entryName = entry.getName();
137+
Path entryPath = Path.of(entryName);
138+
Assert.state(entryPath.toAbsolutePath().equals(entryPath.toAbsolutePath().normalize()),
139+
() -> "Malformed zip entry name '%s'".formatted(entryName));
135140
out.putArchiveEntry(entry);
136141
StreamUtils.copy(in, out);
137142
out.closeArchiveEntry();

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/build/TarGzipBuildpack.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import org.springframework.boot.buildpack.platform.docker.type.Layer;
3232
import org.springframework.boot.buildpack.platform.io.IOConsumer;
33+
import org.springframework.util.Assert;
3334
import org.springframework.util.StreamUtils;
3435

3536
/**
@@ -86,19 +87,23 @@ public void apply(IOConsumer<Layer> layers) throws IOException {
8687
private void copyAndRebaseEntries(OutputStream outputStream) throws IOException {
8788
String id = this.coordinates.getSanitizedId();
8889
Path basePath = Paths.get("/cnb/buildpacks/", id, this.coordinates.getVersion());
89-
try (TarArchiveInputStream tar = new TarArchiveInputStream(
90+
try (TarArchiveInputStream tarInputStream = new TarArchiveInputStream(
9091
new GzipCompressorInputStream(Files.newInputStream(this.path)));
91-
TarArchiveOutputStream output = new TarArchiveOutputStream(outputStream)) {
92-
writeBasePathEntries(output, basePath);
93-
TarArchiveEntry entry = tar.getNextEntry();
92+
TarArchiveOutputStream tarOutputStream = new TarArchiveOutputStream(outputStream)) {
93+
writeBasePathEntries(tarOutputStream, basePath);
94+
TarArchiveEntry entry = tarInputStream.getNextEntry();
9495
while (entry != null) {
95-
entry.setName(basePath + "/" + entry.getName());
96-
output.putArchiveEntry(entry);
97-
StreamUtils.copy(tar, output);
98-
output.closeArchiveEntry();
99-
entry = tar.getNextEntry();
96+
String entryName = entry.getName();
97+
Path entryPath = basePath.resolve(entryName);
98+
Assert.state(entryPath.toAbsolutePath().normalize().startsWith(basePath.toAbsolutePath()),
99+
() -> "Entry '%s' cannot be written outside of '%s'".formatted(entryName, basePath));
100+
entry.setName(basePath + "/" + entryName);
101+
tarOutputStream.putArchiveEntry(entry);
102+
StreamUtils.copy(tarInputStream, tarOutputStream);
103+
tarOutputStream.closeArchiveEntry();
104+
entry = tarInputStream.getNextEntry();
100105
}
101-
output.finish();
106+
tarOutputStream.finish();
102107
}
103108
}
104109

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/io/ZipFileTarArchive.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.io.IOException;
2121
import java.io.InputStream;
2222
import java.io.OutputStream;
23+
import java.nio.file.Path;
2324
import java.util.Enumeration;
2425

2526
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
@@ -94,7 +95,11 @@ private void copy(ZipArchiveEntry zipEntry, InputStream zip, TarArchiveOutputStr
9495

9596
private TarArchiveEntry convert(ZipArchiveEntry zipEntry) {
9697
byte linkFlag = (zipEntry.isDirectory()) ? TarConstants.LF_DIR : TarConstants.LF_NORMAL;
97-
TarArchiveEntry tarEntry = new TarArchiveEntry(zipEntry.getName(), linkFlag, true);
98+
String entryName = zipEntry.getName();
99+
Path entryPath = Path.of(entryName);
100+
Assert.state(entryPath.toAbsolutePath().equals(entryPath.toAbsolutePath().normalize()),
101+
() -> "Malformed zip entry name '%s'".formatted(entryName));
102+
TarArchiveEntry tarEntry = new TarArchiveEntry(entryName, linkFlag, true);
98103
tarEntry.setUserId(this.owner.getUid());
99104
tarEntry.setGroupId(this.owner.getGid());
100105
tarEntry.setModTime(NORMALIZED_MOD_TIME);

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/ImageBuildpackTests.java

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,36 @@ void resolveWhenUnqualifiedReferenceWithInvalidImageReferenceReturnsNull() {
177177
assertThat(buildpack).isNull();
178178
}
179179

180+
@Test
181+
void resolveWhenEntryWouldWriteOutsideOfDestinationThrowsException() throws Exception {
182+
Image image = Image.of(getContent("buildpack-image.json"));
183+
ImageReference imageReference = ImageReference.of("example/buildpack1:latest");
184+
BuildpackResolverContext resolverContext = mock(BuildpackResolverContext.class);
185+
given(resolverContext.getBuildpackLayersMetadata()).willReturn(BuildpackLayersMetadata.fromJson("{}"));
186+
given(resolverContext.fetchImage(eq(imageReference), eq(ImageType.BUILDPACK))).willReturn(image);
187+
willAnswer((invocation) -> withMockLayers(invocation, "..")).given(resolverContext)
188+
.exportImageLayers(eq(imageReference), any());
189+
BuildpackReference reference = BuildpackReference.of("example/buildpack1");
190+
assertThatIllegalStateException().isThrownBy(() -> ImageBuildpack.resolve(resolverContext, reference))
191+
.withMessage("Malformed zip entry name '../cnb/'");
192+
}
193+
180194
private Object withMockLayers(InvocationOnMock invocation) {
195+
return withMockLayers(invocation, "");
196+
}
197+
198+
private Object withMockLayers(InvocationOnMock invocation, String entryPrefix) {
181199
try {
182200
IOBiConsumer<String, TarArchive> consumer = invocation.getArgument(1);
183201
File tarFile = File.createTempFile("create-builder-test-", null);
184202
try (TarArchiveOutputStream tarOut = new TarArchiveOutputStream(new FileOutputStream(tarFile))) {
185203
tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
186-
writeTarEntry(tarOut, "/cnb/");
187-
writeTarEntry(tarOut, "/cnb/buildpacks/");
188-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/");
189-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/");
190-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/buildpack.toml");
191-
writeTarEntry(tarOut, "/cnb/buildpacks/example_buildpack/0.0.1/" + this.longFilePath);
204+
writeTarEntry(tarOut, entryPrefix + "/cnb/");
205+
writeTarEntry(tarOut, entryPrefix + "/cnb/buildpacks/");
206+
writeTarEntry(tarOut, entryPrefix + "/cnb/buildpacks/example_buildpack/");
207+
writeTarEntry(tarOut, entryPrefix + "/cnb/buildpacks/example_buildpack/0.0.1/");
208+
writeTarEntry(tarOut, entryPrefix + "/cnb/buildpacks/example_buildpack/0.0.1/buildpack.toml");
209+
writeTarEntry(tarOut, entryPrefix + "/cnb/buildpacks/example_buildpack/0.0.1/" + this.longFilePath);
192210
tarOut.finish();
193211
}
194212
try (FileInputStream tarFileStream = new FileInputStream(tarFile)) {

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/TarGzipBuildpackTests.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import static org.assertj.core.api.Assertions.assertThat;
2727
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
28+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
2829
import static org.mockito.Mockito.mock;
2930

3031
/**
@@ -91,4 +92,14 @@ void resolveWhenArchiveThatDoesNotExistReturnsNull() {
9192
assertThat(buildpack).isNull();
9293
}
9394

95+
@Test
96+
void resolveWillNotIApplyEntriesOutsideOfOutputLocation() throws Exception {
97+
Path compressedArchive = this.testTarGzip
98+
.createArchive((entryName) -> entryName.endsWith(".toml") ? entryName : "../" + entryName);
99+
BuildpackReference reference = BuildpackReference.of(compressedArchive.toUri().toString());
100+
Buildpack buildpack = TarGzipBuildpack.resolve(this.resolverContext, reference);
101+
assertThatIllegalStateException().isThrownBy(() -> buildpack.apply((layers) -> {
102+
})).withMessage("Entry '../bin/' cannot be written outside of '/cnb/buildpacks/example_buildpack1/0.0.1'");
103+
}
104+
94105
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/build/TestTarGzip.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.nio.file.Paths;
2727
import java.util.ArrayList;
2828
import java.util.List;
29+
import java.util.function.UnaryOperator;
2930

3031
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
3132
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
@@ -51,18 +52,22 @@ class TestTarGzip {
5152
}
5253

5354
Path createArchive() throws Exception {
54-
return createArchive(true);
55+
return createArchive(true, UnaryOperator.identity());
56+
}
57+
58+
Path createArchive(UnaryOperator<String> entryNameProcessor) throws Exception {
59+
return createArchive(true, entryNameProcessor);
5560
}
5661

5762
Path createEmptyArchive() throws Exception {
58-
return createArchive(false);
63+
return createArchive(false, UnaryOperator.identity());
5964
}
6065

61-
private Path createArchive(boolean addContent) throws Exception {
66+
private Path createArchive(boolean addContent, UnaryOperator<String> entryNameProcessor) throws Exception {
6267
Path path = Paths.get(this.buildpackDir.getAbsolutePath(), "buildpack.tar");
6368
Path archive = Files.createFile(path);
6469
if (addContent) {
65-
writeBuildpackContentToArchive(archive);
70+
writeBuildpackContentToArchive(archive, entryNameProcessor);
6671
}
6772
return compressBuildpackArchive(archive);
6873
}
@@ -74,7 +79,8 @@ private Path compressBuildpackArchive(Path archive) throws Exception {
7479
return tgzPath;
7580
}
7681

77-
private void writeBuildpackContentToArchive(Path archive) throws Exception {
82+
private void writeBuildpackContentToArchive(Path archive, UnaryOperator<String> entryNameProcessor)
83+
throws Exception {
7884
StringBuilder buildpackToml = new StringBuilder();
7985
buildpackToml.append("[buildpack]\n");
8086
buildpackToml.append("id = \"example/buildpack1\"\n");
@@ -92,10 +98,10 @@ private void writeBuildpackContentToArchive(Path archive) throws Exception {
9298
echo "---> build"
9399
""";
94100
try (TarArchiveOutputStream tar = new TarArchiveOutputStream(Files.newOutputStream(archive))) {
95-
writeEntry(tar, "buildpack.toml", buildpackToml.toString());
96-
writeEntry(tar, "bin/");
97-
writeEntry(tar, "bin/detect", detectScript);
98-
writeEntry(tar, "bin/build", buildScript);
101+
writeEntry(tar, entryNameProcessor.apply("buildpack.toml"), buildpackToml.toString());
102+
writeEntry(tar, entryNameProcessor.apply("bin/"));
103+
writeEntry(tar, entryNameProcessor.apply("bin/detect"), detectScript);
104+
writeEntry(tar, entryNameProcessor.apply("bin/build"), buildScript);
99105
tar.finish();
100106
}
101107
}

spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/io/ZipFileTarArchiveTests.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
import static org.assertj.core.api.Assertions.assertThat;
3333
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
34+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
3435

3536
/**
3637
* Tests for {@link ZipFileTarArchive}.
@@ -52,7 +53,7 @@ void createWhenZipIsNullThrowsException() {
5253
@Test
5354
void createWhenOwnerIsNullThrowsException() throws Exception {
5455
File file = new File(this.tempDir, "test.zip");
55-
writeTestZip(file);
56+
writeTestZip(file, "");
5657
assertThatIllegalArgumentException().isThrownBy(() -> new ZipFileTarArchive(file, null))
5758
.withMessage("'owner' must not be null");
5859
}
@@ -61,7 +62,7 @@ void createWhenOwnerIsNullThrowsException() throws Exception {
6162
void writeToAdaptsContent() throws Exception {
6263
Owner owner = Owner.of(123, 456);
6364
File file = new File(this.tempDir, "test.zip");
64-
writeTestZip(file);
65+
writeTestZip(file, "");
6566
TarArchive tarArchive = TarArchive.fromZip(file, owner);
6667
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
6768
tarArchive.writeTo(outputStream);
@@ -81,12 +82,23 @@ void writeToAdaptsContent() throws Exception {
8182
}
8283
}
8384

84-
private void writeTestZip(File file) throws IOException {
85+
@Test
86+
void writeToDoesNotIncludeEntriesThatWouldBeWrittenOutsideOfDestination() throws Exception {
87+
Owner owner = Owner.of(123, 456);
88+
File file = new File(this.tempDir, "test.zip");
89+
writeTestZip(file, "../");
90+
TarArchive tarArchive = TarArchive.fromZip(file, owner);
91+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
92+
assertThatIllegalStateException().isThrownBy(() -> tarArchive.writeTo(outputStream))
93+
.withMessage("Malformed zip entry name '../spring/'");
94+
}
95+
96+
private void writeTestZip(File file, String prefix) throws IOException {
8597
try (ZipArchiveOutputStream zip = new ZipArchiveOutputStream(file)) {
86-
ZipArchiveEntry dirEntry = new ZipArchiveEntry("spring/");
98+
ZipArchiveEntry dirEntry = new ZipArchiveEntry(prefix + "spring/");
8799
zip.putArchiveEntry(dirEntry);
88100
zip.closeArchiveEntry();
89-
ZipArchiveEntry fileEntry = new ZipArchiveEntry("spring/boot");
101+
ZipArchiveEntry fileEntry = new ZipArchiveEntry(prefix + "spring/boot");
90102
fileEntry.setUnixMode(0755);
91103
zip.putArchiveEntry(fileEntry);
92104
zip.write("test".getBytes(StandardCharsets.UTF_8));

0 commit comments

Comments
 (0)