Skip to content
18 changes: 18 additions & 0 deletions backend/src/find_api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

import os
from typing import Literal, Optional

from PIL import Image
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


PILLOW_MAX_IMAGE_PIXELS = Image.MAX_IMAGE_PIXELS or 89_478_485


class Settings(BaseSettings):
"""Application settings"""

Expand Down Expand Up @@ -66,6 +71,7 @@ class Settings(BaseSettings):
MAX_BULK_FILES: int = 200
MAX_BULK_TOTAL_SIZE_MB: int = 500
MAX_BULK_COMPRESSION_RATIO: int = 100
MAX_IMAGE_PIXELS: int = PILLOW_MAX_IMAGE_PIXELS
WORKER_TIMEOUT: int = 600
# Trashed assets older than this many days are eligible for permanent
# auto-purge (via POST /trash/purge). 0 disables age-based purging.
Expand Down Expand Up @@ -100,6 +106,7 @@ class Settings(BaseSettings):
"ML_MAX_LOADED_MODELS",
"SESSION_TTL_HOURS",
"INVITE_TTL_HOURS",
"MAX_IMAGE_PIXELS",
)
@classmethod
def validate_positive_int(cls, value: int, info):
Expand All @@ -108,6 +115,17 @@ def validate_positive_int(cls, value: int, info):
raise ValueError(f"{info.field_name} must be greater than 0")
return value

@field_validator("MAX_IMAGE_PIXELS")
@classmethod
def validate_image_pixel_ceiling(cls, value: int):
"""Keep the request-local cap at or below Pillow's process ceiling."""
if value > PILLOW_MAX_IMAGE_PIXELS:
raise ValueError(
"MAX_IMAGE_PIXELS cannot exceed Pillow's built-in safety ceiling "
f"of {PILLOW_MAX_IMAGE_PIXELS} pixels"
)
return value

@model_validator(mode="after")
def validate_remote_ml_config(self):
"""Require remote ML settings when remote mode is enabled."""
Expand Down
33 changes: 21 additions & 12 deletions backend/src/find_api/routers/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,26 @@ def _get_zip_member_basename(member_name: str) -> str:
return member_name.replace("\\", "/").split("/")[-1]


def _verify_image_content(filename: str, file_data: bytes) -> None:
"""Validate image bytes without mutating Pillow process-wide state."""
try:
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
with Image.open(io.BytesIO(file_data)) as img:
width, height = img.size
pixel_count = width * height
if pixel_count > settings.MAX_IMAGE_PIXELS:
raise HTTPException(
400,
f"File {filename} exceeds pixel limit ({pixel_count:,} > {settings.MAX_IMAGE_PIXELS:,})",
)
img.verify()
except HTTPException:
raise
except Image.DecompressionBombError:
raise HTTPException(400, f"File {filename} exceeds the safe pixel limit")
except Exception:
raise HTTPException(400, f"File {filename} is corrupted or not a valid image")


def _ingest_image(
*,
filename: str,
Expand All @@ -234,18 +254,7 @@ def _ingest_image(
if not detected_type.startswith("image/"):
raise HTTPException(400, f"File {filename} is not an image")

# Verify image content and protect against decompression bombs
try:
# Set a reasonable limit for image pixels (e.g., 100MP)
Image.MAX_IMAGE_PIXELS = 100_000_000
with Image.open(io.BytesIO(file_data)) as img:
img.verify()
# Re-open to check dimensions (verify() consumes the file pointer)
# This is still lazy and doesn't decode pixels.
with Image.open(io.BytesIO(file_data)) as img2:
_ = img2.size
except Exception:
raise HTTPException(400, f"File {filename} is corrupted or not a valid image")
_verify_image_content(filename, file_data)

file_size = len(file_data)
max_size = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
Expand Down
59 changes: 59 additions & 0 deletions backend/tests/test_upload.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import concurrent.futures
import io
import os
import zipfile
from unittest.mock import patch

import pytest
from PIL import Image
from find_api.core.config import PILLOW_MAX_IMAGE_PIXELS, Settings
from find_api.models.media import Media
from find_api.routers.upload import _verify_image_content


def get_valid_image_bytes():
Expand Down Expand Up @@ -114,6 +118,61 @@ def test_missing_files_returns_422(self, client):
assert response.status_code == 422


class TestPixelLimitValidation:
"""Image pixel-limit validation (thread-safe, Pillow ceiling-aware)."""

def test_pixel_limit_normal_image(self, client):
"""Normal image within pixel limit should succeed."""
img = Image.new("RGB", (100, 100), color="red")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)

response = client.post(
"/api/upload",
files=[("files", ("normal.png", buf.getvalue(), "image/png"))],
)
assert response.status_code == 200
assert response.json()["results"][0]["status"] == "uploaded"

def test_pixel_limit_oversized_image(self, client):
"""Image exceeding MAX_IMAGE_PIXELS should be rejected."""
with patch("find_api.routers.upload.settings.MAX_IMAGE_PIXELS", 100):
img = Image.new("RGB", (50, 50), color="blue")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)

response = client.post(
"/api/upload",
files=[("files", ("oversized.png", buf.getvalue(), "image/png"))],
)
assert response.status_code == 400
assert "exceeds pixel limit" in response.json()["detail"].lower()

def test_pixel_limit_concurrent_validation(self, client):
"""Concurrent uploads should each validate independently without global mutation."""
pillow_limit = Image.MAX_IMAGE_PIXELS

def validate_image(size):
img = Image.new("RGB", (size, size), color="green")
buf = io.BytesIO()
img.save(buf, format="PNG")
_verify_image_content(f"image_{size}.png", buf.getvalue())
return size

with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
futures = [executor.submit(validate_image, size) for size in [10, 50, 100]]
results = [f.result() for f in concurrent.futures.as_completed(futures)]

assert sorted(results) == [10, 50, 100]
assert Image.MAX_IMAGE_PIXELS == pillow_limit

def test_config_rejects_limit_above_pillow_ceiling(self):
with pytest.raises(ValueError, match="Pillow's built-in safety ceiling"):
Settings(MAX_IMAGE_PIXELS=PILLOW_MAX_IMAGE_PIXELS + 1)


class TestMultipartUploadLimit:
"""The multipart endpoint enforces MAX_BULK_FILES before ingestion."""

Expand Down
Loading