diff --git a/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java b/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java
index 8305918..2e5ebe3 100644
--- a/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java
+++ b/src/main/java/com/ibrasoft/lensbridge/config/ImageProcessingProperties.java
@@ -12,4 +12,16 @@ public class ImageProcessingProperties {
private int thumbnailHeight;
private double thumbnailQuality;
private String thumbnailFolder;
+
+ /**
+ * Ceiling on the resolution the server will decode, in megapixels.
+ *
+ * The other fields here bound the thumbnail we produce; this one bounds the input. A
+ * decompression bomb is a few kilobytes on the wire that declares a resolution large enough
+ * to exhaust the heap when ImageIO expands it — every byte-count limit in the upload path
+ * lets it straight through. 50 MP sits well above any consumer camera (a 100 MP phone
+ * sensor is the only realistic exception) while capping a single decode at roughly 200 MB
+ * of ARGB raster.
+ */
+ private int maxMegapixels = 50;
}
diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java
index b2aba38..cd75372 100644
--- a/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/ibrasoft/lensbridge/exception/GlobalExceptionHandler.java
@@ -196,6 +196,13 @@ public ResponseEntity handleInvalidContentType(InvalidContentTypeExcepti
.body(ErrorResponse.of(ex.getMessage()));
}
+ @ExceptionHandler(ImageDimensionsExceededException.class)
+ public ResponseEntity handleImageDimensionsExceeded(ImageDimensionsExceededException ex) {
+ log.warn("Rejected oversized image: {}", ex.getMessage());
+ return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
+ .body(ErrorResponse.of(ex.getMessage()));
+ }
+
@ExceptionHandler(EventNotAcceptingUploadsException.class)
public ResponseEntity handleEventNotAcceptingUploads(EventNotAcceptingUploadsException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/ImageDimensionsExceededException.java b/src/main/java/com/ibrasoft/lensbridge/exception/ImageDimensionsExceededException.java
new file mode 100644
index 0000000..8be2411
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/exception/ImageDimensionsExceededException.java
@@ -0,0 +1,25 @@
+package com.ibrasoft.lensbridge.exception;
+
+import lombok.Getter;
+
+/**
+ * Thrown when an image's header declares more pixels than the server is willing to decode.
+ *
+ * This is the decompression-bomb case: the file is small enough to satisfy every byte-count
+ * limit, and would still allocate gigabytes the moment ImageIO expanded it into a raster.
+ */
+@Getter
+public class ImageDimensionsExceededException extends RuntimeException {
+
+ private final long width;
+ private final long height;
+ private final long maxPixels;
+
+ public ImageDimensionsExceededException(long width, long height, long maxPixels) {
+ super("Image resolution too large: " + width + "x" + height + " exceeds the "
+ + (maxPixels / 1_000_000L) + " megapixel limit");
+ this.width = width;
+ this.height = height;
+ this.maxPixels = maxPixels;
+ }
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java b/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java
index 8456933..6a5d037 100644
--- a/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java
+++ b/src/main/java/com/ibrasoft/lensbridge/exception/InvalidContentTypeException.java
@@ -10,4 +10,16 @@ public InvalidContentTypeException(String contentType) {
super("Content type not allowed: " + contentType);
this.contentType = contentType;
}
+
+ /**
+ * For rejections where the type alone does not explain the problem — bytes that match no
+ * known image signature, or that contradict the type the client declared.
+ *
+ * @param contentType the type the client declared, kept for callers that inspect it
+ * @param detail the message returned to the client
+ */
+ public InvalidContentTypeException(String contentType, String detail) {
+ super(detail);
+ this.contentType = contentType;
+ }
}
diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java b/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java
index 61a803a..4e8c537 100644
--- a/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java
+++ b/src/main/java/com/ibrasoft/lensbridge/service/ImageProcessingService.java
@@ -23,6 +23,7 @@ public class ImageProcessingService {
private final R2StorageService r2StorageService;
private final UploadRepository uploadRepository;
private final ImageProcessingProperties properties;
+ private final ImageValidationService imageValidationService;
@Async
public CompletableFuture generateThumbnail(Upload upload) {
@@ -32,6 +33,12 @@ public CompletableFuture generateThumbnail(Upload upload) {
byte[] originalBytes = r2StorageService.getObjectBytes(objectKey);
+ // Second line of defence. The upload path validates before an Upload row exists, but
+ // this worker runs on a shared async pool and will decode whatever the object holds —
+ // including bytes that reached R2 by some other route. Header-only, so it costs
+ // nothing and, crucially, runs before Thumbnailator allocates a raster.
+ imageValidationService.validateDimensions(originalBytes);
+
ByteArrayOutputStream out = new ByteArrayOutputStream();
Thumbnails.of(new ByteArrayInputStream(originalBytes))
.size(properties.getThumbnailWidth(), properties.getThumbnailHeight())
diff --git a/src/main/java/com/ibrasoft/lensbridge/service/ImageValidationService.java b/src/main/java/com/ibrasoft/lensbridge/service/ImageValidationService.java
new file mode 100644
index 0000000..fc2c974
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/service/ImageValidationService.java
@@ -0,0 +1,232 @@
+package com.ibrasoft.lensbridge.service;
+
+import com.ibrasoft.lensbridge.config.ImageProcessingProperties;
+import com.ibrasoft.lensbridge.config.UploadProperties;
+import com.ibrasoft.lensbridge.exception.ImageDimensionsExceededException;
+import com.ibrasoft.lensbridge.exception.InvalidContentTypeException;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.stream.ImageInputStream;
+import javax.imageio.stream.MemoryCacheImageInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Server-side inspection of image bytes.
+ *
+ * Nothing upstream of this class looks at what was actually uploaded: the presign step
+ * validates a client-supplied {@code contentType} string, and the completion step only
+ * compares byte counts and a client-supplied SHA-256 against the stored object. Both pass
+ * happily for an object that is not an image at all, or that is an image declaring a
+ * resolution large enough to exhaust the heap the moment ImageIO decodes it.
+ *
+ * Two checks close that gap, and both must run before any decode:
+ *
+ * {@link #validateContainerType} sniffs the leading bytes and requires the real
+ * container to be on the upload allowlist and to match what the client declared.
+ * {@link #validateDimensions} reads only the image header through
+ * {@link ImageReader#getWidth(int)} / {@link ImageReader#getHeight(int)} and rejects
+ * anything above {@code image-processing.max-megapixels}. A decompression bomb is
+ * tiny on the wire and enormous in memory, so byte-length limits never catch it.
+ *
+ * Deliberately implemented against the JDK's own ImageIO readers plus a small magic-byte
+ * table rather than pulling in a content-detection dependency; the allowlist is five
+ * formats long and their signatures are fixed.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ImageValidationService {
+
+ /** Bytes needed to identify the longest signature we look for (ISO-BMFF brand at 8..12). */
+ private static final int SIGNATURE_WINDOW = 12;
+
+ private static final byte[] JPEG_MAGIC = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF};
+ private static final byte[] PNG_MAGIC =
+ {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
+ private static final byte[] GIF87A_MAGIC = "GIF87a".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] GIF89A_MAGIC = "GIF89a".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] RIFF_MAGIC = "RIFF".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] WEBP_FORM = "WEBP".getBytes(StandardCharsets.US_ASCII);
+ private static final byte[] FTYP_BOX = "ftyp".getBytes(StandardCharsets.US_ASCII);
+
+ /**
+ * ISO-BMFF brands that mean "this file holds HEIF/HEIC image data". {@code mif1}/{@code msf1}
+ * are the generic HEIF brands Apple and Android both emit alongside the {@code heic} family.
+ */
+ private static final Set HEIC_BRANDS = Set.of(
+ "heic", "heix", "heim", "heis",
+ "hevc", "hevx", "hevm", "hevs",
+ "mif1", "msf1");
+
+ private final UploadProperties uploadProperties;
+ private final ImageProcessingProperties imageProcessingProperties;
+
+ /**
+ * Runs both checks in the order that keeps untrusted bytes away from a decoder: identify
+ * the container first, then read the header for its declared size.
+ *
+ * @param bytes the object as actually stored
+ * @param declaredContentType the content type the client claimed, possibly with parameters
+ * @return the sniffed content type
+ */
+ public String validateImageBytes(byte[] bytes, String declaredContentType) {
+ String sniffed = validateContainerType(bytes, declaredContentType);
+ validateDimensions(bytes);
+ return sniffed;
+ }
+
+ /**
+ * Confirms the real container type is on {@code uploads.allowed-file-types} and is the one
+ * the client said it was uploading.
+ *
+ * @return the sniffed content type
+ * @throws InvalidContentTypeException if the bytes are unrecognised, not allowlisted, or
+ * disagree with {@code declaredContentType}
+ */
+ public String validateContainerType(byte[] bytes, String declaredContentType) {
+ String sniffed = detectContentType(bytes);
+ if (sniffed == null) {
+ log.warn("Rejected upload: bytes match no supported image signature (declared '{}')",
+ declaredContentType);
+ throw new InvalidContentTypeException(declaredContentType,
+ "File is not a recognized image; its contents match no supported format");
+ }
+
+ List allowed = uploadProperties.getAllowedFileTypes();
+ if (allowed == null || !allowed.contains(sniffed)) {
+ throw new InvalidContentTypeException(sniffed);
+ }
+
+ String declared = normalizeContentType(declaredContentType);
+ if (!sniffed.equals(declared)) {
+ log.warn("Rejected upload: declared content type '{}' but bytes are {}",
+ declaredContentType, sniffed);
+ // The message names only the declared type; echoing the sniffed one back would tell
+ // a prober exactly which signatures the server recognises.
+ throw new InvalidContentTypeException(declaredContentType,
+ "File contents do not match the declared content type '"
+ + declaredContentType + "'");
+ }
+
+ return sniffed;
+ }
+
+ /**
+ * Rejects images whose header declares more pixels than {@code image-processing.max-megapixels}.
+ *
+ * Only the header is parsed — {@link ImageReader#getWidth(int)} on a PNG reads IHDR and stops,
+ * so a bomb is rejected without ever allocating its raster. Formats the JDK has no reader for
+ * (HEIC, WebP) are passed through: nothing downstream can decode them either, so they carry no
+ * decompression risk from ImageIO.
+ *
+ * @throws ImageDimensionsExceededException if the declared resolution is over the ceiling
+ */
+ public void validateDimensions(byte[] bytes) {
+ long maxPixels = maxPixels();
+
+ try (ImageInputStream input = new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) {
+ Iterator readers = ImageIO.getImageReaders(input);
+ if (!readers.hasNext()) {
+ log.debug("No ImageIO reader for uploaded bytes; skipping dimension check");
+ return;
+ }
+
+ ImageReader reader = readers.next();
+ try {
+ // ignoreMetadata=true keeps the reader from walking ancillary chunks.
+ reader.setInput(input, true, true);
+ long width = reader.getWidth(0);
+ long height = reader.getHeight(0);
+ long pixels = width * height;
+
+ if (pixels > maxPixels) {
+ log.warn("Rejected upload: {}x{} = {} pixels exceeds the {} pixel ceiling",
+ width, height, pixels, maxPixels);
+ throw new ImageDimensionsExceededException(width, height, maxPixels);
+ }
+
+ log.debug("Image dimensions accepted: {}x{} ({} pixels)", width, height, pixels);
+ } finally {
+ reader.dispose();
+ }
+ } catch (ImageDimensionsExceededException e) {
+ throw e;
+ } catch (IOException | RuntimeException e) {
+ // A reader claimed the format, then could not parse its header. That is a file
+ // pretending to be something it is not, and we cannot vouch for what a decoder
+ // would do with it.
+ log.warn("Could not read image dimensions: {}", e.getMessage());
+ throw new InvalidContentTypeException(null,
+ "Image header could not be read; the file may be corrupt or truncated");
+ }
+ }
+
+ /**
+ * @return the configured ceiling in pixels
+ */
+ public long maxPixels() {
+ return (long) imageProcessingProperties.getMaxMegapixels() * 1_000_000L;
+ }
+
+ /**
+ * Identifies the container from its leading bytes.
+ *
+ * @return the canonical content type, or {@code null} if the signature is not one we accept
+ */
+ public String detectContentType(byte[] bytes) {
+ if (bytes == null || bytes.length < SIGNATURE_WINDOW) {
+ return null;
+ }
+ if (startsWith(bytes, JPEG_MAGIC, 0)) {
+ return "image/jpeg";
+ }
+ if (startsWith(bytes, PNG_MAGIC, 0)) {
+ return "image/png";
+ }
+ if (startsWith(bytes, GIF87A_MAGIC, 0) || startsWith(bytes, GIF89A_MAGIC, 0)) {
+ return "image/gif";
+ }
+ // RIFF....WEBP — the four bytes between the two tags are the chunk length.
+ if (startsWith(bytes, RIFF_MAGIC, 0) && startsWith(bytes, WEBP_FORM, 8)) {
+ return "image/webp";
+ }
+ // ISO-BMFF: a 4-byte box length, then "ftyp", then the major brand.
+ if (startsWith(bytes, FTYP_BOX, 4)) {
+ String brand = new String(bytes, 8, 4, StandardCharsets.US_ASCII)
+ .toLowerCase(Locale.ROOT);
+ if (HEIC_BRANDS.contains(brand)) {
+ return "image/heic";
+ }
+ }
+ return null;
+ }
+
+ /** Strips media-type parameters and case so {@code image/JPEG; charset=x} compares equal. */
+ public static String normalizeContentType(String contentType) {
+ if (contentType == null) {
+ return null;
+ }
+ int semicolon = contentType.indexOf(';');
+ String base = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
+ return base.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static boolean startsWith(byte[] bytes, byte[] signature, int offset) {
+ if (bytes.length < offset + signature.length) {
+ return false;
+ }
+ return Arrays.equals(bytes, offset, offset + signature.length, signature, 0, signature.length);
+ }
+}
diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java
index c96aba7..04563e1 100644
--- a/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java
+++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadLimitsService.java
@@ -25,8 +25,17 @@ public class UploadLimitsService {
private final UploadService uploadService;
private final AuthorityResolver authorityResolver;
+ /**
+ * Pre-flight limits for the presign step.
+ *
+ * {@code contentType} here is only ever a client-supplied string — no bytes exist yet — so
+ * this check keeps obviously-wrong requests from getting a signed URL and nothing more. The
+ * authoritative check is {@code ImageValidationService}, which sniffs the stored object at
+ * completion time.
+ */
public void validateUpload(UUID userId, User user, long fileSize, String contentType) {
- if (!uploadProperties.getAllowedFileTypes().contains(contentType)) {
+ String normalizedType = ImageValidationService.normalizeContentType(contentType);
+ if (normalizedType == null || !uploadProperties.getAllowedFileTypes().contains(normalizedType)) {
throw new InvalidContentTypeException(contentType);
}
diff --git a/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java
index d346220..fb0a829 100644
--- a/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java
+++ b/src/main/java/com/ibrasoft/lensbridge/service/UploadWorkflowService.java
@@ -5,6 +5,8 @@
import com.ibrasoft.lensbridge.dto.upload.response.UploadCompletionResponse;
import com.ibrasoft.lensbridge.exception.ApiResponseException;
import com.ibrasoft.lensbridge.exception.EventNotAcceptingUploadsException;
+import com.ibrasoft.lensbridge.exception.ImageDimensionsExceededException;
+import com.ibrasoft.lensbridge.exception.InvalidContentTypeException;
import com.ibrasoft.lensbridge.model.auth.User;
import com.ibrasoft.lensbridge.model.minbar.BoardEvent;
import com.ibrasoft.lensbridge.model.upload.Upload;
@@ -16,8 +18,11 @@
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
+import java.util.HexFormat;
import java.util.UUID;
@Service
@@ -30,6 +35,7 @@ public class UploadWorkflowService {
private final UploadLimitsService uploadLimitsService;
private final BoardEventRepository boardEventRepository;
private final ImageProcessingService imageProcessingService;
+ private final ImageValidationService imageValidationService;
@Value("${lensbridge.app.daysCutoffForPastEvent:7}")
private int daysCutoffForPastEvent;
@@ -86,8 +92,8 @@ public UploadCompletionResponse completeUpload(
}
try {
- verifyIntegrity(objectKey, fileSize, expectedSha256);
- } catch (ApiResponseException e) {
+ verifyIntegrity(objectKey, fileSize, expectedSha256, contentType);
+ } catch (ApiResponseException | InvalidContentTypeException | ImageDimensionsExceededException e) {
r2StorageService.deleteObject(objectKey);
throw e;
}
@@ -129,7 +135,18 @@ private void requireVerified(User user) {
}
}
- private void verifyIntegrity(String objectKey, long expectedSize, String expectedSha256) {
+ /**
+ * Establishes that the stored object is what the client said it uploaded.
+ *
+ * Size and hash only prove the bytes are the ones the client intended to send — they say
+ * nothing about whether those bytes are an image at all. {@link ImageValidationService} then
+ * sniffs the real container type and reads the image header, so a ZIP declared as
+ * {@code image/png} and a decompression bomb are both rejected here, before the object gets
+ * an {@code Upload} row and before the async thumbnail worker tries to decode it.
+ */
+ private void verifyIntegrity(String objectKey, long expectedSize, String expectedSha256,
+ String declaredContentType) {
+ byte[] bytes;
try {
long storedSize = r2StorageService.getObjectMetadata(objectKey).contentLength();
if (storedSize != expectedSize) {
@@ -139,7 +156,11 @@ private void verifyIntegrity(String objectKey, long expectedSize, String expecte
"File size mismatch");
}
- String actualHash = r2StorageService.calculateSha256Hash(objectKey);
+ // One fetch serves both the hash and the content checks below; the size check above
+ // has already bounded how much we are about to pull into the heap.
+ bytes = r2StorageService.getObjectBytes(objectKey);
+
+ String actualHash = sha256Hex(bytes);
if (!actualHash.equalsIgnoreCase(expectedSha256)) {
throw new ApiResponseException(
HttpStatus.BAD_REQUEST,
@@ -156,5 +177,14 @@ private void verifyIntegrity(String objectKey, long expectedSize, String expecte
ErrorResponse.of("Failed to verify file integrity"),
"Integrity verification error");
}
+
+ // Outside the catch above: these throw their own mapped exceptions, and wrapping them in
+ // a generic "failed to verify integrity" would hide why the upload was refused.
+ imageValidationService.validateImageBytes(bytes, declaredContentType);
+ }
+
+ private static String sha256Hex(byte[] bytes) throws NoSuchAlgorithmException {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(bytes));
}
}
diff --git a/src/main/resources/application.properties.example b/src/main/resources/application.properties.example
index 165d227..999faa8 100644
--- a/src/main/resources/application.properties.example
+++ b/src/main/resources/application.properties.example
@@ -70,6 +70,11 @@ image-processing.thumbnail-width=400
image-processing.thumbnail-height=400
image-processing.thumbnail-quality=0.7
image-processing.thumbnail-folder=thumbnails/
+# Largest input resolution the server will decode, in megapixels. Guards against
+# decompression bombs: a few KB on the wire that expand to gigabytes of raster once
+# ImageIO reads them, which no byte-size limit can catch. 50 MP is above any consumer
+# camera and caps one decode at roughly 200 MB of ARGB. Raise only with headroom to match.
+image-processing.max-megapixels=50
# Rate limiting
ratelimit.requests=100
diff --git a/src/test/java/com/ibrasoft/lensbridge/service/ImageValidationServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/ImageValidationServiceTest.java
new file mode 100644
index 0000000..d93f9a2
--- /dev/null
+++ b/src/test/java/com/ibrasoft/lensbridge/service/ImageValidationServiceTest.java
@@ -0,0 +1,168 @@
+package com.ibrasoft.lensbridge.service;
+
+import com.ibrasoft.lensbridge.config.ImageProcessingProperties;
+import com.ibrasoft.lensbridge.config.UploadProperties;
+import com.ibrasoft.lensbridge.exception.ImageDimensionsExceededException;
+import com.ibrasoft.lensbridge.exception.InvalidContentTypeException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.zip.CRC32;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Byte-level validation of uploaded images.
+ *
+ *
Before this existed, the only checks on an uploaded object were its declared
+ * {@code Content-Type} (a client-supplied string) and its byte length against R2's stored
+ * {@code Content-Length}. Nothing looked at the bytes. So a file could claim {@code image/png},
+ * be a ZIP, and reach {@code Thumbnails.of(...)} — and a PNG declaring an enormous resolution in
+ * its header could be a few hundred bytes on the wire and still ask ImageIO to allocate a
+ * multi-gigabyte raster on the async thumbnail pool.
+ *
+ *
The decompression bomb below is the real thing: a valid PNG header advertising
+ * 40000x40000 (1.6 gigapixels) whose image data is never decoded, because the dimension check
+ * runs off the header before {@code ImageIO.read} is ever called.
+ */
+class ImageValidationServiceTest {
+
+ private ImageValidationService service;
+
+ @BeforeEach
+ void setUp() {
+ UploadProperties uploadProperties = new UploadProperties();
+ uploadProperties.setAllowedFileTypes(
+ List.of("image/jpeg", "image/png", "image/heic", "image/webp", "image/gif"));
+ ImageProcessingProperties imageProcessingProperties = new ImageProcessingProperties();
+ imageProcessingProperties.setMaxMegapixels(50);
+ service = new ImageValidationService(uploadProperties, imageProcessingProperties);
+ }
+
+ // ── fixtures ──────────────────────────────────────────────────────────────
+
+ private static byte[] realPng(int width, int height) throws IOException {
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ }
+
+ /**
+ * A PNG whose IHDR advertises {@code width x height} but which carries no pixel data. Small
+ * on the wire, ruinous to decode — the classic decompression bomb shape.
+ */
+ private static byte[] pngClaimingDimensions(int width, int height) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.writeBytes(new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ ByteArrayOutputStream ihdr = new ByteArrayOutputStream();
+ ihdr.writeBytes("IHDR".getBytes(StandardCharsets.US_ASCII));
+ ihdr.writeBytes(intBytes(width));
+ ihdr.writeBytes(intBytes(height));
+ ihdr.write(8); // bit depth
+ ihdr.write(2); // colour type: truecolour
+ ihdr.write(0); // compression
+ ihdr.write(0); // filter
+ ihdr.write(0); // interlace
+ byte[] chunk = ihdr.toByteArray();
+ out.writeBytes(intBytes(chunk.length - 4)); // length excludes the type field
+ out.writeBytes(chunk);
+ CRC32 crc = new CRC32();
+ crc.update(chunk);
+ out.writeBytes(intBytes((int) crc.getValue()));
+ return out.toByteArray();
+ }
+
+ private static byte[] intBytes(int value) {
+ return new byte[]{
+ (byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value};
+ }
+
+ /** Local file header of a ZIP archive. Not an image by any reading of the bytes. */
+ private static byte[] zipBytes() {
+ byte[] bytes = new byte[64];
+ bytes[0] = 'P';
+ bytes[1] = 'K';
+ bytes[2] = 0x03;
+ bytes[3] = 0x04;
+ return bytes;
+ }
+
+ // ── container type ────────────────────────────────────────────────────────
+
+ @Test
+ void acceptsAGenuinePngDeclaredAsPng() throws IOException {
+ assertThat(service.validateImageBytes(realPng(8, 8), "image/png")).isEqualTo("image/png");
+ }
+
+ @Test
+ void rejectsAZipDeclaredAsPng() {
+ assertThatThrownBy(() -> service.validateImageBytes(zipBytes(), "image/png"))
+ .isInstanceOf(InvalidContentTypeException.class);
+ }
+
+ @Test
+ void rejectsAGenuinePngDeclaredAsJpeg() throws IOException {
+ // The declared type is on the allowlist and the bytes are a real image; they simply are
+ // not the same thing. Trusting the declaration is what let non-images through before.
+ assertThatThrownBy(() -> service.validateImageBytes(realPng(8, 8), "image/jpeg"))
+ .isInstanceOf(InvalidContentTypeException.class);
+ }
+
+ @Test
+ void rejectionMessageDoesNotEchoTheSniffedType() throws IOException {
+ // Naming the detected format would tell a prober exactly which signatures are recognised.
+ assertThatThrownBy(() -> service.validateImageBytes(realPng(8, 8), "image/jpeg"))
+ .hasMessageContaining("image/jpeg")
+ .hasMessageNotContaining("image/png");
+ }
+
+ @Test
+ void rejectsEmptyAndTruncatedInput() {
+ assertThatThrownBy(() -> service.validateImageBytes(new byte[0], "image/png"))
+ .isInstanceOf(InvalidContentTypeException.class);
+ assertThatThrownBy(() -> service.validateImageBytes(new byte[]{(byte) 0x89, 'P'}, "image/png"))
+ .isInstanceOf(InvalidContentTypeException.class);
+ }
+
+ // ── dimensions ────────────────────────────────────────────────────────────
+
+ @Test
+ void rejectsADecompressionBombBeforeDecodingIt() {
+ // 40000x40000 = 1.6 gigapixels, from a payload of well under a kilobyte.
+ byte[] bomb = pngClaimingDimensions(40_000, 40_000);
+ assertThat(bomb.length).isLessThan(1024);
+
+ assertThatThrownBy(() -> service.validateDimensions(bomb))
+ .isInstanceOf(ImageDimensionsExceededException.class);
+ }
+
+ @Test
+ void acceptsAnImageUnderThePixelCeiling() {
+ assertThatCode(() -> service.validateDimensions(pngClaimingDimensions(4_000, 4_000)))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void pixelCeilingIsConfigurable() {
+ UploadProperties uploadProperties = new UploadProperties();
+ uploadProperties.setAllowedFileTypes(List.of("image/png"));
+ ImageProcessingProperties strict = new ImageProcessingProperties();
+ strict.setMaxMegapixels(1);
+ ImageValidationService strictService = new ImageValidationService(uploadProperties, strict);
+
+ // 2000x2000 = 4 MP: fine at the default 50 MP, rejected at 1 MP.
+ byte[] bytes = pngClaimingDimensions(2_000, 2_000);
+ assertThatCode(() -> service.validateDimensions(bytes)).doesNotThrowAnyException();
+ assertThatThrownBy(() -> strictService.validateDimensions(bytes))
+ .isInstanceOf(ImageDimensionsExceededException.class);
+ }
+}