All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 51s
sanitize_image декодировал изображение целиком (img.load) ДО ресайза, а Pillow MAX_IMAGE_PIXELS оставался дефолтным (~89 MP). Хорошо сжимаемый ≤10 MB аплоад с заявленными ~89-178 MP разворачивался в raw RGB буфер на сотни MB до thumbnail — OOM-kill backend'а (768 MiB, #2214), усиление ×12 слотов на estimate. Defense in depth: - _MAX_PIXELS = 40 MP: проверка img.size (из хедера, без декода) ДО img.load; превышение → ImageSanitizationError без загрузки полного буфера. - Image.MAX_IMAGE_PIXELS понижен до 40 MP — Pillow сам бросает DecompressionBombError на любом декод-пути в обход явной проверки. - Явный except Image.DecompressionBombError + re-raise ImageSanitizationError (byte/pixel cap) до широкого except — грациозный reject, не 500. - _MAX_BYTES backstop (endpoint уже режет 10 MB → 413, но сервис standalone). Контракт caller'а не меняется: reject → ImageSanitizationError → HTTP 400. Тест tests/services/test_image_sanitizer.py: happy-path, resize, pixel-flood без декода (load не вызывается), byte-backstop, граница cap, garbage bytes.
116 lines
5.3 KiB
Python
116 lines
5.3 KiB
Python
"""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
|