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: + *

    + *
  1. {@link #validateContainerType} sniffs the leading bytes and requires the real + * container to be on the upload allowlist and to match what the client declared.
  2. + *
  3. {@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.
  4. + *
+ * 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); + } +}