gendesign/tradein-mvp/backend/app/services/image_sanitizer.py
bot-backend 1c9f0e1161
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
fix(tradein/photos): защита от pixel-flood DoS в image_sanitizer
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.
2026-07-12 22:12:12 +03:00

116 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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