From e2e5449df186256a7e2f7590a7c6c8c30ac00b07 Mon Sep 17 00:00:00 2001
From: "Zickermann, Jan" <jan.zickermann@dataport.de>
Date: Mon, 3 Jun 2024 09:52:11 +0200
Subject: [PATCH] OZG-5412 xta-adapter: Throw on suspicious ZIP compression
 ratio

---
 .../eingang/xta/zip/ReadableZipEntry.java     |  9 ++-
 .../eingang/xta/zip/ZipFileExtractor.java     | 31 +++++--
 .../eingang/xta/zip/ReadableZipEntryTest.java | 80 +++++++++++++++++++
 .../eingang/xta/zip/TestZipFileFactory.java   | 14 +++-
 .../eingang/xta/zip/ZipFileExtractorTest.java | 50 +++++++-----
 5 files changed, 153 insertions(+), 31 deletions(-)
 create mode 100644 xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntryTest.java

diff --git a/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntry.java b/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntry.java
index f82f0090a..fec9014ca 100644
--- a/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntry.java
+++ b/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntry.java
@@ -5,6 +5,7 @@ import java.io.InputStream;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
+import de.ozgcloud.eingang.common.errorhandling.TechnicalException;
 import lombok.Builder;
 
 @Builder
@@ -13,8 +14,12 @@ record ReadableZipEntry(ZipEntry zipEntry, ZipFile parentZip) {
 		return parentZip.getInputStream(zipEntry);
 	}
 
-	public Long getSize() {
-		return zipEntry.getSize();
+	public Long getPositiveSize() {
+		var size = zipEntry.getSize();
+		if (size < 0) {
+			throw new TechnicalException("Size of ZIP entry is unknown.");
+		}
+		return size;
 	}
 
 	public String getName() {
diff --git a/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractor.java b/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractor.java
index 66480bc98..8f162061d 100644
--- a/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractor.java
+++ b/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractor.java
@@ -20,23 +20,38 @@ import de.ozgcloud.eingang.common.formdata.IncomingFile;
 
 // TODO Resolve code duplication with ZipAttachmentReader in semantik-adapter common.zip
 // Note: In contrast to the ZipAttachmentReader, here, the zip file is not included in list of extracted files
-// Further, there is no ZIP_MAX_THRESHOLD detection, instead the reader will abort if the inputstream exceeds zipFile.getSize().
+// Further, the suspicious compression ratio ZIP_MAX_THRESHOLD is evaluated on the whole zipFile, instead of individual entries
 @Component
 public class ZipFileExtractor {
 
+	static final double ZIP_MAX_THRESHOLD = 100;
 	static final int ZIP_MAX_TOTAL_SIZE = 500 * 1024 * 1024;
 	static final int ZIP_MAX_ENTRIES = 100;
 
 	public List<IncomingFile> extractIncomingFilesSafely(IncomingFile zipIncomingFile) {
 		var zipFile = zipIncomingFile.getFile();
-		verifySizeLimit(zipFile);
+		verifyLimits(zipFile);
 		return extractIncomingFiles(zipFile);
 	}
 
-	void verifySizeLimit(File zipFile) {
-		var totalSize = sumUncompressedEntrySizes(zipFile);
-		if (totalSize > getZipMaxTotalSize()) {
-			throw new TechnicalException("Expect uncompressed size %s to be smaller than %d!".formatted(totalSize, getZipMaxTotalSize()));
+	void verifyLimits(File zipFile) {
+		var uncompressedSize = sumUncompressedEntrySizes(zipFile);
+		verifySizeLimit(uncompressedSize);
+		verifyCompressionRatio(zipFile, uncompressedSize);
+	}
+
+	private void verifySizeLimit(long uncompressedSize) {
+		if (uncompressedSize > getZipMaxTotalSize()) {
+			throw new TechnicalException("Expect uncompressed size %s to be smaller than %d!".formatted(uncompressedSize, getZipMaxTotalSize()));
+		}
+	}
+
+	private void verifyCompressionRatio(File zipFile, long totalSize) {
+		var compressionRatio = (double) totalSize / zipFile.length();
+		if (compressionRatio > ZIP_MAX_THRESHOLD) {
+			throw new TechnicalException(
+					"Expect compression ratio %s to be smaller than %s! A zip bomb attack is suspected!".formatted(compressionRatio,
+							ZIP_MAX_THRESHOLD));
 		}
 	}
 
@@ -45,7 +60,7 @@ public class ZipFileExtractor {
 	}
 
 	Long sumUncompressedEntrySizes(File zipFile) {
-		return mapZipEntries(zipFile, ReadableZipEntry::getSize)
+		return mapZipEntries(zipFile, ReadableZipEntry::getPositiveSize)
 				.stream()
 				.mapToLong(Long::longValue)
 				.sum();
@@ -57,7 +72,7 @@ public class ZipFileExtractor {
 
 	private IncomingFile mapReadableEntryToIncomingFile(ReadableZipEntry entry) {
 		File file;
-		try (var inputStream = new LimitedInputStream(entry.getInputStream(), entry.getSize())) {
+		try (var inputStream = new LimitedInputStream(entry.getInputStream(), entry.getPositiveSize())) {
 			file = TempFileUtils.writeTmpFile(inputStream);
 		} catch (IOException | de.ozgcloud.common.errorhandling.TechnicalException e) {
 			throw new TechnicalException("Failed reading zip file element %s!".formatted(entry.getName()), e);
diff --git a/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntryTest.java b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntryTest.java
new file mode 100644
index 000000000..e5bfea088
--- /dev/null
+++ b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ReadableZipEntryTest.java
@@ -0,0 +1,80 @@
+package de.ozgcloud.eingang.xta.zip;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.InputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+
+import de.ozgcloud.eingang.common.errorhandling.TechnicalException;
+import lombok.SneakyThrows;
+
+class ReadableZipEntryTest {
+
+	@Mock
+	ZipEntry zipEntry;
+
+	@Mock
+	ZipFile zipFile;
+
+	private ReadableZipEntry readableZipEntry;
+
+	@BeforeEach
+	void mock() {
+		readableZipEntry = ReadableZipEntry.builder()
+				.zipEntry(zipEntry)
+				.parentZip(zipFile)
+				.build();
+
+	}
+
+	@DisplayName("get input stream")
+	@Nested
+	class TestGetInputStream {
+		@Mock
+		private InputStream inputStream;
+
+		@SneakyThrows
+		@DisplayName("should return input stream")
+		@Test
+		void shouldReturnInputStream() {
+			when(zipFile.getInputStream(zipEntry)).thenReturn(inputStream);
+
+			var inputStreamResult = readableZipEntry.getInputStream();
+
+			assertThat(inputStreamResult).isEqualTo(inputStream);
+		}
+	}
+
+	@DisplayName("get positive size")
+	@Nested
+	class TestGetPositiveSize {
+		@DisplayName("should return size")
+		@Test
+		void shouldReturnSize() {
+			var size = 123L;
+			when(zipEntry.getSize()).thenReturn(size);
+
+			var sizeResult = readableZipEntry.getPositiveSize();
+
+			assertThat(sizeResult).isEqualTo(size);
+		}
+
+		@DisplayName("should throw if size is negative")
+		@Test
+		void shouldThrowIfSizeIsNegative() {
+			var size = -1L;
+			when(zipEntry.getSize()).thenReturn(size);
+
+			assertThatThrownBy(() -> readableZipEntry.getPositiveSize()).isInstanceOf(TechnicalException.class);
+		}
+
+	}
+}
diff --git a/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/TestZipFileFactory.java b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/TestZipFileFactory.java
index cb721d211..8ac1b3595 100644
--- a/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/TestZipFileFactory.java
+++ b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/TestZipFileFactory.java
@@ -40,10 +40,17 @@ public class TestZipFileFactory {
 	}
 
 	public static File createTempZipBomb(int maxTotalSize) {
+		return overwriteFileWithZipEntrySize(
+				createTempZipWithSingleEntry(maxTotalSize * 2),
+				maxTotalSize
+		);
+	}
+
+	private static File createTempZipWithSingleEntry(int entrySize) {
 		var file = TempFileUtils.createTmpFile().toFile();
 		try (var zipOutputStream = new ZipOutputStream(new FileOutputStream(file))) {
 			var entry = new ZipEntry(EXPANDED_ENTRY_NAME);
-			var content = "A".repeat(2 * maxTotalSize).getBytes();
+			var content = "A".repeat(entrySize).getBytes();
 
 			zipOutputStream.putNextEntry(entry);
 			zipOutputStream.write(content);
@@ -52,10 +59,13 @@ public class TestZipFileFactory {
 		} catch (IOException e) {
 			throw new RuntimeException("Failed to create temporary zip file", e);
 		}
+		return file;
+	}
 
+	private static File overwriteFileWithZipEntrySize(File file, int newSize) {
 		try {
 			var zipFileBytes = IOUtils.toByteArray(new FileInputStream(file));
-			overwriteZipEntrySize(zipFileBytes, EXPANDED_ENTRY_NAME, maxTotalSize);
+			overwriteZipEntrySize(zipFileBytes, EXPANDED_ENTRY_NAME, newSize);
 
 			// Write the adjusted ZIP content back to the file
 			try (var fos = new FileOutputStream(file)) {
diff --git a/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractorTest.java b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractorTest.java
index aeb945cc8..d23838973 100644
--- a/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractorTest.java
+++ b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/ZipFileExtractorTest.java
@@ -54,16 +54,17 @@ class ZipFileExtractorTest {
 			outIncomingFiles = List.of(outIncomingFile);
 
 			when(incomingZipFile.getFile()).thenReturn(zipFile);
-			doNothing().when(extractor).verifySizeLimit(zipFile);
+			doNothing().when(extractor).verifyLimits(zipFile);
+
 			doReturn(outIncomingFiles).when(extractor).extractIncomingFiles(zipFile);
 		}
 
-		@DisplayName("should call verify size limit")
+		@DisplayName("should call verify limits")
 		@Test
 		void shouldCallVerifySizeLimit() {
 			extractor.extractIncomingFilesSafely(incomingZipFile);
 
-			verify(extractor).verifySizeLimit(zipFile);
+			verify(extractor).verifyLimits(zipFile);
 		}
 
 		@DisplayName("should return")
@@ -75,26 +76,37 @@ class ZipFileExtractorTest {
 		}
 	}
 
-	@DisplayName("verify size limit")
+	@DisplayName("verify limits")
 	@Nested
-	class TestVerifySizeLimit {
+	class TestVerifyLimits {
 		@Mock
 		File zipFile;
 
 		@DisplayName("should return")
 		@Test
 		void shouldReturn() {
-			doReturn((long) ZipFileExtractor.ZIP_MAX_TOTAL_SIZE).when(extractor).sumUncompressedEntrySizes(zipFile);
+			when(zipFile.length()).thenReturn((long) ZIP_MAX_TOTAL_SIZE / 2);
+			doReturn((long) ZIP_MAX_TOTAL_SIZE).when(extractor).sumUncompressedEntrySizes(zipFile);
 
-			extractor.verifySizeLimit(zipFile);
+			extractor.verifyLimits(zipFile);
 		}
 
-		@DisplayName("should throw if limit exceeded")
+		@DisplayName("should throw if size limit exceeded")
 		@Test
-		void shouldThrowIfLimitExceeded() {
-			doReturn((long) ZipFileExtractor.ZIP_MAX_TOTAL_SIZE + 1).when(extractor).sumUncompressedEntrySizes(zipFile);
+		void shouldThrowIfSizeLimitExceeded() {
+			doReturn((long) ZIP_MAX_TOTAL_SIZE + 1).when(extractor).sumUncompressedEntrySizes(zipFile);
 
-			assertThatThrownBy(() -> extractor.verifySizeLimit(zipFile))
+			assertThatThrownBy(() -> extractor.verifyLimits(zipFile))
+					.isInstanceOf(TechnicalException.class);
+		}
+
+		@DisplayName("should throw if ratio exceeded")
+		@Test
+		void shouldThrowIfRatioExceeded() {
+			when(zipFile.length()).thenReturn(1L);
+			doReturn((long) ZIP_MAX_THRESHOLD + 1).when(extractor).sumUncompressedEntrySizes(zipFile);
+
+			assertThatThrownBy(() -> extractor.verifyLimits(zipFile))
 					.isInstanceOf(TechnicalException.class);
 		}
 	}
@@ -164,13 +176,13 @@ class ZipFileExtractorTest {
 		@DisplayName("should throw with too many entries")
 		@Test
 		void shouldThrow() {
-			var zipFile = createTempZipFile(IntStream.range(0, ZIP_MAX_ENTRIES + 1).mapToObj(i -> TestZipEntry.builder()
+			var zipFile = createIncomingFile(createTempZipFile(IntStream.range(0, ZIP_MAX_ENTRIES + 1).mapToObj(i -> TestZipEntry.builder()
 					.name("file%d.txt".formatted(i))
 					.content(toBytes("A".repeat(2)))
 					.build()
-			).toList());
+			).toList()));
 
-			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(createIncomingFile(zipFile)))
+			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(zipFile))
 					.isInstanceOf(TechnicalException.class);
 		}
 
@@ -179,9 +191,9 @@ class ZipFileExtractorTest {
 		@Test
 		void shouldThrowWithFakeGetSize() {
 			doReturn(SMALLER_MAX_ZIP_FILE_SIZE).when(extractor).getZipMaxTotalSize();
-			var zipBomb = createTempZipBomb(SMALLER_MAX_ZIP_FILE_SIZE);
+			var zipBomb = createIncomingFile(createTempZipBomb(SMALLER_MAX_ZIP_FILE_SIZE));
 
-			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(createIncomingFile(zipBomb)))
+			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(zipBomb))
 					.isInstanceOf(TechnicalException.class)
 					.hasRootCauseMessage(LimitedInputStream.LIMITED_EXCEEDED_MESSAGE);
 		}
@@ -190,13 +202,13 @@ class ZipFileExtractorTest {
 		@Test
 		void shouldThrowWithTooLargeSize() {
 			doReturn(SMALLER_MAX_ZIP_FILE_SIZE).when(extractor).getZipMaxTotalSize();
-			var zipFile = createTempZipFile(List.of(TestZipEntry.builder()
+			var zipFile = createIncomingFile(createTempZipFile(List.of(TestZipEntry.builder()
 					.name("toolargefile.txt")
 					.content(toBytes("A".repeat(SMALLER_MAX_ZIP_FILE_SIZE + 1)))
 					.build()
-			));
+			)));
 
-			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(createIncomingFile(zipFile)))
+			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(zipFile))
 					.isInstanceOf(TechnicalException.class);
 		}
 
-- 
GitLab