"""Image sanitization for user-uploaded photos. Re-encodes inbound images through Pillow to: - strip EXIF (GPS lat/lon, device fingerprint, owner metadata) - normalize byte stream (a polyglot JPEG with appended payload survives MIME check but is dropped on re-encode — only the actual image survives) - enforce max dimension (defend against pixel-flood DoS in downstream renders) - produce uniform JPEG output (smaller storage; no transparency support needed for apartment photos) Closes finding #6 from 2026-05-24 trade-in audit. """ from __future__ import annotations import io import logging from PIL import Image, ImageOps, UnidentifiedImageError logger = logging.getLogger(__name__) # Max longest edge after re-encode. 2400px = enough for retina apt photo, # small enough to bound storage & decode cost. _MAX_DIMENSION = 2400 # Hard cap on declared pixel count (width*height) enforced BEFORE any decode. # A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load() # inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert # copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots. # 40 MP (~8000×5000) sits far above any real listing photo yet well below the # ~89 MP default Pillow bomb threshold. img.size is read from the header on # open() without decoding pixels, so this check is cheap and pre-decode. _MAX_PIXELS = 40_000_000 # Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this # service is standalone; keep it self-defending against oversized inputs). _MAX_BYTES = 10 * 1024 * 1024 # Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its # ~89 MP default to our cap, so oversized declared dimensions raise even on any # decode path that bypasses the explicit size guard below. Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # JPEG quality. 85 is industry standard for photos — visually lossless, # ~70% size reduction vs original phone JPEGs. _JPEG_QUALITY = 85 # Output content type after sanitization (all inputs transcoded to JPEG). SANITIZED_CONTENT_TYPE = "image/jpeg" class ImageSanitizationError(ValueError): """Raised when inbound bytes cannot be decoded as an image.""" def sanitize_image(content: bytes) -> tuple[bytes, str]: """Decode arbitrary image bytes and re-encode as clean JPEG. Args: content: raw uploaded bytes from the client. Returns: (sanitized_bytes, "image/jpeg") — both ready for storage/serving. Raises: ImageSanitizationError: bytes are not a recognized image format, or the declared dimensions / byte size exceed the pixel-flood safety caps. """ if len(content) > _MAX_BYTES: raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap") try: with Image.open(io.BytesIO(content)) as img: # #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes # from the header (no pixel decode); loading an oversized declared # image would inflate a small file into a hundreds-of-MB RGB buffer. width, height = img.size if width * height > _MAX_PIXELS: logger.warning( "image rejected: %dx%d (%d px) exceeds %d px cap", width, height, width * height, _MAX_PIXELS, ) raise ImageSanitizationError( f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap" ) img.load() # force full decode; surfaces UnidentifiedImageError early # Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone # portrait photos (Orientation=6/8) end up rotated 90/270° once the tag # is gone. No-op when there is no orientation tag. img = ImageOps.exif_transpose(img) # Convert any mode (RGBA, P, CMYK, ...) to RGB for JPEG output. if img.mode != "RGB": img = img.convert("RGB") # Resize if oversized; preserve aspect ratio. if max(img.size) > _MAX_DIMENSION: img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION), Image.Resampling.LANCZOS) buf = io.BytesIO() # save() does NOT carry over .info / EXIF unless we pass exif=...; we don't. img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True) return buf.getvalue(), SANITIZED_CONTENT_TYPE except ImageSanitizationError: raise # already a handled, user-facing rejection (byte/pixel cap) except UnidentifiedImageError as e: raise ImageSanitizationError("not a recognized image format") from e except Image.DecompressionBombError as e: # Any decode path that outran the size guard above (e.g. >2× cap) lands here. logger.warning("decompression bomb rejected: %s", e) raise ImageSanitizationError("image dimensions exceed safe limits") from e except Exception as e: # PIL can raise broad errors (OSError on truncated, DecompressionBombWarning # promoted to error, ...); normalize to one user-facing error type. logger.warning("image sanitization failed: %s", e) raise ImageSanitizationError(f"image decode failed: {e}") from e