diff --git a/semantik-adapter/src/main/java/de/ozgcloud/eingang/semantik/enginebased/afm/AfmEngineBasedAdapter.java b/semantik-adapter/src/main/java/de/ozgcloud/eingang/semantik/enginebased/afm/AfmEngineBasedAdapter.java
index 8f6733cd1d89b68580e8ab128fae413714b87e61..5d7224c75ce7ff634737b79d28aeaf6e05931c6d 100644
--- a/semantik-adapter/src/main/java/de/ozgcloud/eingang/semantik/enginebased/afm/AfmEngineBasedAdapter.java
+++ b/semantik-adapter/src/main/java/de/ozgcloud/eingang/semantik/enginebased/afm/AfmEngineBasedAdapter.java
@@ -23,16 +23,14 @@
  */
 package de.ozgcloud.eingang.semantik.enginebased.afm;
 
-import java.util.List;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Component;
-
 import de.ozgcloud.eingang.common.formdata.FormData;
 import de.ozgcloud.eingang.common.formdata.FormDataUtils;
 import de.ozgcloud.eingang.semantik.enginebased.EngineBasedSemantikAdapter;
 import de.ozgcloud.eingang.semantik.enginebased.afm.intelliform.IntelliFormRepresentationAdapter;
-import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
 
 @Component
 public class AfmEngineBasedAdapter implements EngineBasedSemantikAdapter {
diff --git a/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/LimitedInputStream.java b/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/LimitedInputStream.java
new file mode 100644
index 0000000000000000000000000000000000000000..ee5dc7a8b70e7fe0241a60d94e98ed783f47f66e
--- /dev/null
+++ b/xta-adapter/src/main/java/de/ozgcloud/eingang/xta/zip/LimitedInputStream.java
@@ -0,0 +1,42 @@
+package de.ozgcloud.eingang.xta.zip;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class LimitedInputStream extends FilterInputStream {
+	static final String LIMITED_EXCEEDED_MESSAGE = "Read limit exceeded";
+
+	private final long maxSize;
+	long bytesRead;
+
+	public LimitedInputStream(InputStream in, long maxSize) {
+		super(in);
+		this.maxSize = maxSize;
+		this.bytesRead = 0;
+	}
+
+	@Override
+	public int read() throws IOException {
+		var byteValue = super.read();
+		if (byteValue != -1) {
+			updateAndVerifyReadLimit(1);
+		}
+		return byteValue;
+	}
+
+	@Override
+	public int read(byte[] b, int off, int len) throws IOException {
+		return updateAndVerifyReadLimit(super.read(b, off, len));
+	}
+
+	private int updateAndVerifyReadLimit(int newBytesRead) throws IOException {
+		if (newBytesRead != -1) {
+			bytesRead += newBytesRead;
+			if (bytesRead > maxSize) {
+				throw new IOException(LIMITED_EXCEEDED_MESSAGE);
+			}
+		}
+		return newBytesRead;
+	}
+}
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 f82f0090a248495ff4f46194b3e2344f807f5294..fec9014ca3c6ac8c743517d6b45da904b5e9b7aa 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 255f7eac2b3562f77724382c1216529b2c06ff0d..ed6cf366789a55865390948001fac349f8db3093 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,28 +20,44 @@ 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.
+// 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 > ZIP_MAX_TOTAL_SIZE) {
-			throw new TechnicalException("Expect uncompressed size %s to be smaller than %d!".formatted(totalSize, ZIP_MAX_TOTAL_SIZE));
+	void verifyLimits(File zipFile) {
+		var uncompressedSize = sumUncompressedEntrySizes(zipFile);
+		verifySizeLimit(uncompressedSize);
+		verifyCompressionRatio(zipFile, uncompressedSize);
+	}
+
+	private void verifySizeLimit(long uncompressedSize) {
+		if (uncompressedSize > ZIP_MAX_TOTAL_SIZE) {
+			throw new TechnicalException("Expect uncompressed size %s to be smaller than %d!".formatted(uncompressedSize, ZIP_MAX_TOTAL_SIZE));
 		}
 	}
 
+	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));
+		}
+	}
+
+
 	Long sumUncompressedEntrySizes(File zipFile) {
-		return mapZipEntries(zipFile, ReadableZipEntry::getSize)
+		return mapZipEntries(zipFile, ReadableZipEntry::getPositiveSize)
 				.stream()
 				.mapToLong(Long::longValue)
 				.sum();
@@ -53,9 +69,9 @@ public class ZipFileExtractor {
 
 	private IncomingFile mapReadableEntryToIncomingFile(ReadableZipEntry entry) {
 		File file;
-		try (var inputStream = entry.getInputStream()) {
+		try (var inputStream = new LimitedInputStream(entry.getInputStream(), entry.getPositiveSize())) {
 			file = TempFileUtils.writeTmpFile(inputStream);
-		} catch (IOException e) {
+		} catch (IOException | de.ozgcloud.common.errorhandling.TechnicalException e) {
 			throw new TechnicalException("Failed reading zip file element %s!".formatted(entry.getName()), e);
 		}
 		return createIncomingFile(file, entry.zipEntry());
diff --git a/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/LimitedInputStreamTest.java b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/LimitedInputStreamTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b01c316d94f4bc994601401daaabacb4e0134efc
--- /dev/null
+++ b/xta-adapter/src/test/java/de/ozgcloud/eingang/xta/zip/LimitedInputStreamTest.java
@@ -0,0 +1,141 @@
+package de.ozgcloud.eingang.xta.zip;
+
+import static de.ozgcloud.eingang.xta.zip.LimitedInputStream.*;
+import static org.assertj.core.api.Assertions.*;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+
+import org.apache.commons.io.IOUtils;
+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 lombok.SneakyThrows;
+
+class LimitedInputStreamTest {
+
+	private static final int READ_LIMIT = 10;
+	private static final String STRING_WITH_READ_LIMIT_LENGTH = "A".repeat(READ_LIMIT);
+	private static final String STRING_WITH_MORE_THAN_READ_LIMIT_LENGTH = "B".repeat(READ_LIMIT + 1);
+
+	private LimitedInputStream limitedInputStream;
+
+	private InputStream createStringInputSteam(String string) {
+		return new ByteArrayInputStream(string.getBytes());
+	}
+
+	@SneakyThrows
+	private String readInputStreamToString(InputStream inputStream) {
+		return IOUtils.toString(inputStream, Charset.defaultCharset());
+	}
+
+	@SneakyThrows
+	@DisplayName("should succeed if read limit is not exceeded")
+	@Test
+	void shouldSucceedIfReadLimitIsNotExceeded() {
+		limitedInputStream = new LimitedInputStream(createStringInputSteam(STRING_WITH_READ_LIMIT_LENGTH), READ_LIMIT);
+
+		var outputString = readInputStreamToString(limitedInputStream);
+
+		assertThat(outputString).isEqualTo(STRING_WITH_READ_LIMIT_LENGTH);
+	}
+
+	@DisplayName("should fail if read limit is exceeded")
+	@Test
+	void shouldFailIfReadLimitIsExceeded() {
+		limitedInputStream = new LimitedInputStream(createStringInputSteam(STRING_WITH_MORE_THAN_READ_LIMIT_LENGTH), READ_LIMIT);
+
+		assertThatThrownBy(() -> readInputStreamToString(limitedInputStream))
+				.isInstanceOf(IOException.class)
+				.hasMessage(LIMITED_EXCEEDED_MESSAGE);
+	}
+
+	@DisplayName("read")
+	@Nested
+	class TestRead {
+
+		@SneakyThrows
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			limitedInputStream = createLimitedInputStream();
+
+			var result = limitedInputStream.read();
+			assertThat(result).isEqualTo(STRING_WITH_READ_LIMIT_LENGTH.getBytes()[0]);
+		}
+
+		@SneakyThrows
+		@DisplayName("should advance bytesRead")
+		@Test
+		void shouldAdvanceBytesRead() {
+			limitedInputStream = createLimitedInputStream();
+			limitedInputStream.bytesRead = READ_LIMIT - 1;
+
+			limitedInputStream.read();
+			assertThat(limitedInputStream.bytesRead).isEqualTo(READ_LIMIT);
+		}
+
+		@DisplayName("should throw if exceeded")
+		@Test
+		void shouldThrowIfExceeded() {
+			limitedInputStream = createLimitedInputStreamWithExceeding();
+			limitedInputStream.bytesRead = READ_LIMIT;
+
+			assertThatThrownBy(() -> limitedInputStream.read()).isInstanceOf(IOException.class);
+		}
+
+	}
+
+	@DisplayName("read into buffer")
+	@Nested
+	class TestReadIntoBuffer {
+		private byte[] buffer;
+
+		@BeforeEach
+		void mock() {
+			buffer = new byte[READ_LIMIT];
+		}
+
+		@SneakyThrows
+		@DisplayName("should return")
+		@Test
+		void shouldReturn() {
+			limitedInputStream = createLimitedInputStream();
+
+			var result = limitedInputStream.read(buffer);
+
+			assertThat(result).isEqualTo(READ_LIMIT);
+		}
+
+		@SneakyThrows
+		@DisplayName("should advance bytesRead")
+		@Test
+		void shouldAdvanceBytesRead() {
+			limitedInputStream = createLimitedInputStream();
+
+			limitedInputStream.read(buffer);
+			assertThat(limitedInputStream.bytesRead).isEqualTo(READ_LIMIT);
+		}
+
+		@DisplayName("should throw if exceeded")
+		@Test
+		void shouldThrowIfExceeded() {
+			limitedInputStream = createLimitedInputStreamWithExceeding();
+			limitedInputStream.bytesRead = 1;
+
+			assertThatThrownBy(() -> limitedInputStream.read(buffer)).isInstanceOf(IOException.class);
+		}
+	}
+
+	private LimitedInputStream createLimitedInputStream() {
+		return new LimitedInputStream(createStringInputSteam(STRING_WITH_READ_LIMIT_LENGTH), READ_LIMIT);
+	}
+
+	private LimitedInputStream createLimitedInputStreamWithExceeding() {
+		return new LimitedInputStream(createStringInputSteam(STRING_WITH_MORE_THAN_READ_LIMIT_LENGTH), READ_LIMIT);
+	}
+}
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 0000000000000000000000000000000000000000..e5bfea0888cf15d8791777a8bf826e13868c489c
--- /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 1cb3039d44b1acc2767ae36159c4802457cff8e4..8ac1b3595af4ae63e720a765d8fdced3a9730906 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
@@ -1,18 +1,23 @@
 package de.ozgcloud.eingang.xta.zip;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.util.List;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 
+import org.apache.commons.io.IOUtils;
+
 import de.ozgcloud.common.binaryfile.TempFileUtils;
 import lombok.Builder;
 import lombok.Getter;
 
 public class TestZipFileFactory {
 
+	private static final String EXPANDED_ENTRY_NAME = "bomb.txt";
+
 	@Builder
 	@Getter
 	public static class TestZipEntry {
@@ -33,4 +38,76 @@ public class TestZipFileFactory {
 			throw new RuntimeException("Failed to create temporary zip file", e);
 		}
 	}
+
+	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(entrySize).getBytes();
+
+			zipOutputStream.putNextEntry(entry);
+			zipOutputStream.write(content);
+			zipOutputStream.closeEntry();
+
+		} 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, newSize);
+
+			// Write the adjusted ZIP content back to the file
+			try (var fos = new FileOutputStream(file)) {
+				fos.write(zipFileBytes);
+			}
+		} catch (IOException e) {
+			throw new RuntimeException("Failed to adjust size header of zip file", e);
+		}
+		return file;
+	}
+
+	private static void overwriteZipEntrySize(byte[] zipFileBytes, String entryName, int newSize) throws IOException {
+		// Modify the uncompressed size entry size in the central directory structure (which is located at the end)
+		// Zip structure spec: https://www.iana.org/assignments/media-types/application/zip
+		var entryNameBytes = entryName.getBytes();
+
+		var lastIndexOfEntryName = findLastStartIndex(zipFileBytes, entryNameBytes);
+		if (lastIndexOfEntryName == -1) {
+			throw new IOException("ZIP entry not found: " + entryName);
+		}
+		var uncompressedSizeFieldStartOffset = lastIndexOfEntryName - (4 * 2 + 5 * 2 + 4);
+		writeIntToByteArray(newSize, zipFileBytes, uncompressedSizeFieldStartOffset);
+	}
+
+	private static void writeIntToByteArray(int value, byte[] array, int offset) {
+		array[offset] = (byte) (value & 0xFF);
+		array[offset + 1] = (byte) ((value >> 8) & 0xFF);
+		array[offset + 2] = (byte) ((value >> 16) & 0xFF);
+		array[offset + 3] = (byte) ((value >> 24) & 0xFF);
+	}
+
+	private static int findLastStartIndex(byte[] haystack, byte[] needle) {
+		var matchOffset = 0;
+		for (var i = haystack.length - 1; i >= needle.length; i--) {
+			if (haystack[i] == needle[needle.length - 1 - matchOffset]) {
+				if (++matchOffset == needle.length) {
+					return i;
+				}
+			} else {
+				matchOffset = 0;
+			}
+		}
+		return -1;
+	}
 }
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 5a79c2a8e2fbd90ba33ac64012991085b098282f..d23838973567f333510c7b14d5ae4536cd119655 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
@@ -1,6 +1,7 @@
 package de.ozgcloud.eingang.xta.zip;
 
 import static de.ozgcloud.eingang.xta.zip.TestZipFileFactory.*;
+import static de.ozgcloud.eingang.xta.zip.ZipFileExtractor.*;
 import static org.assertj.core.api.Assertions.*;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;
@@ -26,6 +27,7 @@ import org.springframework.util.MimeTypeUtils;
 
 import de.ozgcloud.eingang.common.errorhandling.TechnicalException;
 import de.ozgcloud.eingang.common.formdata.IncomingFile;
+import lombok.SneakyThrows;
 
 class ZipFileExtractorTest {
 
@@ -52,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")
@@ -73,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);
 		}
 	}
@@ -154,6 +168,57 @@ class ZipFileExtractorTest {
 		}
 	}
 
+	@DisplayName("extract zip bomb")
+	@Nested
+	class TestExtractZipBomb {
+		private static final int SMALLER_MAX_ZIP_FILE_SIZE = 2 * 1024;
+
+		@DisplayName("should throw with too many entries")
+		@Test
+		void shouldThrow() {
+			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()));
+
+			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(zipFile))
+					.isInstanceOf(TechnicalException.class);
+		}
+
+		@SneakyThrows
+		@DisplayName("should throw with fake getSize")
+		@Test
+		void shouldThrowWithFakeGetSize() {
+			doReturn(SMALLER_MAX_ZIP_FILE_SIZE).when(extractor).getZipMaxTotalSize();
+			var zipBomb = createIncomingFile(createTempZipBomb(SMALLER_MAX_ZIP_FILE_SIZE));
+
+			assertThatThrownBy(() -> extractor.extractIncomingFilesSafely(zipBomb))
+					.isInstanceOf(TechnicalException.class)
+					.hasRootCauseMessage(LimitedInputStream.LIMITED_EXCEEDED_MESSAGE);
+		}
+
+		@DisplayName("should throw with too large size")
+		@Test
+		void shouldThrowWithTooLargeSize() {
+			doReturn(SMALLER_MAX_ZIP_FILE_SIZE).when(extractor).getZipMaxTotalSize();
+			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(zipFile))
+					.isInstanceOf(TechnicalException.class);
+		}
+
+		private IncomingFile createIncomingFile(File file) {
+			return IncomingFile.builder()
+					.file(file)
+					.build();
+		}
+	}
+
 	@DisplayName("create incoming file")
 	@Nested
 	class TestCreateIncomingFile {
@@ -271,7 +336,7 @@ class ZipFileExtractorTest {
 		@DisplayName("should throw if max entries exceeded")
 		@Test
 		void shouldThrowIfMaxEntriesExceeded() {
-			var zipWithTooManyEntries = createTempZipFile(IntStream.range(0, ZipFileExtractor.ZIP_MAX_ENTRIES + 1)
+			var zipWithTooManyEntries = createTempZipFile(IntStream.range(0, ZIP_MAX_ENTRIES + 1)
 					.mapToObj(i -> TestZipEntry.builder()
 							.name("test%d.txt".formatted(i))
 							.content(toBytes("test file %d".formatted(i)))
@@ -285,7 +350,7 @@ class ZipFileExtractorTest {
 		@DisplayName("should map with mapping function")
 		@Test
 		void shouldMapWithMappingFunction() {
-			var expectedNumberList = IntStream.range(0, ZipFileExtractor.ZIP_MAX_ENTRIES).boxed().toList();
+			var expectedNumberList = IntStream.range(0, ZIP_MAX_ENTRIES).boxed().toList();
 			var zipFile = createTempZipFile(expectedNumberList.stream()
 					.map(i -> TestZipEntry.builder()
 							.name("%d".formatted(i))