"""Untrusted image upload validation and normalisation. Security contract (see docs/SECURITY.md): * hard byte cap enforced before decoding; * content sniffing with Pillow -- the client-supplied filename, extension and Content-Type are never trusted; * decompression-bomb guards (Pillow ``MAX_IMAGE_PIXELS`` + explicit pixel and dimension caps); * SVG rejected outright (scriptable XML, not an image for our purposes); * output is a freshly re-encoded RGB PNG (EXIF/ICC/XMP metadata stripped, orientation applied), downscaled so the pure-Python per-pixel PNG readers in ``forge/`` and the LLM image payload stay cheap. """ from __future__ import annotations import io from dataclasses import dataclass from PIL import Image, ImageOps # Pillow's own bomb guard; load_settings() re-applies the configured value # at import time of the pipeline (module-level default keeps tests simple). Image.MAX_IMAGE_PIXELS = 40_000_000 ALLOWED_FORMATS = {"PNG", "JPEG", "WEBP", "GIF", "BMP"} class ImageRejected(Exception): """Raised when an upload fails validation. Carries a user-safe reason.""" def __init__(self, reason: str, code: str = "image_rejected") -> None: super().__init__(reason) self.reason = reason self.code = code @dataclass(frozen=True) class NormalizedImage: png_bytes: bytes width: int height: int original_format: str original_width: int original_height: int downscaled: bool def _sniff(data: bytes) -> Image.Image: """Open enough of the image to identify its format and dimensions. Do not decode pixels here. Callers must enforce declared dimension and pixel caps before invoking ``load()``; that ordering is the protection against small compressed files expanding into excessive work or memory. """ if not data: raise ImageRejected("The uploaded file is empty.", "empty_file") # Cheap pre-check: SVG (and other XML) is rejected before Pillow ever # sees it -- it is a document, not a raster image. head = data[:512].lstrip().lower() if head.startswith(b"<") and (b" NormalizedImage: """Validate raw upload bytes and return a normalised RGB PNG. Raises ImageRejected with a user-safe reason on any violation. """ if len(data) > max_bytes: raise ImageRejected( f"The file is {len(data) / (1024 * 1024):.1f} MiB; the limit is " f"{max_bytes // (1024 * 1024)} MiB.", "file_too_large", ) Image.MAX_IMAGE_PIXELS = max_pixels img = _sniff(data) original_format = (img.format or "").upper() original_width, original_height = img.size if original_width <= 0 or original_height <= 0: img.close() raise ImageRejected( "The image has invalid dimensions.", "image_too_large", ) if original_width * original_height > max_pixels: img.close() raise ImageRejected( f"The image has {original_width * original_height:,} pixels; the limit is " f"{max_pixels:,}.", "image_too_large", ) if max(original_width, original_height) > max_side: img.close() raise ImageRejected( f"The image is {original_width}x{original_height}px; the longest side may " f"be at most {max_side}px.", "image_too_large", ) source = img try: # Pixel decode happens only after the cheap header-based limits above. try: img.load() except Image.DecompressionBombError as exc: raise ImageRejected( "The image is unreasonably large (decompression guard).", "image_too_large", ) from exc except Exception as exc: raise ImageRejected( "The uploaded file could not be fully decoded as an image.", "not_an_image", ) from exc # Apply EXIF orientation, then flatten to RGB (drops alpha compositing # surprises and strips all metadata when re-encoded). img = ImageOps.exif_transpose(img) if img.mode == "P": img = img.convert("RGBA") if img.mode in ("RGBA", "LA"): background = Image.new("RGB", img.size, (255, 255, 255)) alpha = img.getchannel("A") if "A" in img.getbands() else None background.paste(img.convert("RGB"), mask=alpha) img = background elif img.mode != "RGB": img = img.convert("RGB") downscaled = False if max(img.size) > normalize_max_side: img = ImageOps.contain(img, (normalize_max_side, normalize_max_side)) downscaled = True buf = io.BytesIO() img.save(buf, format="PNG", optimize=True) return NormalizedImage( png_bytes=buf.getvalue(), width=img.size[0], height=img.size[1], original_format=original_format, original_width=original_width, original_height=original_height, downscaled=downscaled, ) finally: source.close() if img is not source: img.close()