From a7bfc7ee8db04a3de8eeb45bfd3ca1611ab59dca Mon Sep 17 00:00:00 2001 From: IK Date: Thu, 3 Sep 2026 08:17:00 +0700 Subject: [PATCH] Validate screenshot MIME and filename metadata --- apps/api/app/main.py | 2 +- apps/api/app/storage.py | 11 ++++++++++- apps/api/tests/test_api.py | 2 +- apps/api/tests/test_storage.py | 13 ++++++++++++- docs/ROADMAP.md | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 77e93a3..b108940 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -182,7 +182,7 @@ def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) -> raise HTTPException(status_code=409, detail="screenshot already uploaded") raw = screenshot.file.read(settings.screenshot_max_bytes + 1) try: - report.screenshot_key = upload_screenshot(raw) + report.screenshot_key = upload_screenshot(raw, filename=screenshot.filename, content_type=screenshot.content_type) except ScreenshotError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc db.commit() diff --git a/apps/api/app/storage.py b/apps/api/app/storage.py index a99ce59..024ad94 100644 --- a/apps/api/app/storage.py +++ b/apps/api/app/storage.py @@ -17,6 +17,14 @@ class ScreenshotError(ValueError): ALLOWED_FORMATS = {"JPEG": ("jpg", "image/jpeg"), "PNG": ("png", "image/png"), "WEBP": ("webp", "image/webp")} +ALLOWED_UPLOADS = {"image/jpeg": {".jpg", ".jpeg"}, "image/png": {".png"}, "image/webp": {".webp"}} + + +def validate_upload_metadata(filename: str | None, content_type: str | None) -> None: + mime = (content_type or "").lower() + suffix = f".{(filename or '').rsplit('.', 1)[-1].lower()}" if "." in (filename or "") else "" + if mime not in ALLOWED_UPLOADS or suffix not in ALLOWED_UPLOADS[mime]: + raise ScreenshotError("filename extension and MIME type must match JPEG, PNG or WebP") @lru_cache @@ -50,7 +58,8 @@ def prepare_image(raw: bytes) -> tuple[bytes, str, str]: raise ScreenshotError("file is not a valid image") from exc -def upload_screenshot(raw: bytes) -> str: +def upload_screenshot(raw: bytes, *, filename: str | None = None, content_type: str | None = None) -> str: + validate_upload_metadata(filename, content_type) body, extension, mime = prepare_image(raw) key = f"reports/{uuid.uuid4()}.{extension}" s3 = client() diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index ea5781b..8576d75 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -118,7 +118,7 @@ def test_admin_can_start_and_list_official_import(monkeypatch) -> None: def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json() - monkeypatch.setattr("app.main.upload_screenshot", lambda raw: "reports/test.jpg" if raw == b"image-bytes" else "unexpected") + monkeypatch.setattr("app.main.upload_screenshot", lambda raw, **metadata: "reports/test.jpg" if raw == b"image-bytes" and metadata == {"filename": "catch.jpg", "content_type": "image/jpeg"} else "unexpected") response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}) assert response.status_code == 204 duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")}) diff --git a/apps/api/tests/test_storage.py b/apps/api/tests/test_storage.py index 62da795..28c2bac 100644 --- a/apps/api/tests/test_storage.py +++ b/apps/api/tests/test_storage.py @@ -5,7 +5,7 @@ import io import pytest from PIL import Image -from app.storage import ScreenshotError, prepare_image +from app.storage import ScreenshotError, prepare_image, validate_upload_metadata def test_prepare_image_removes_metadata() -> None: @@ -26,3 +26,14 @@ def test_prepare_image_removes_metadata() -> None: def test_prepare_image_rejects_non_image() -> None: with pytest.raises(ScreenshotError, match="valid image"): prepare_image(b"not an image") + + +def test_upload_metadata_requires_matching_supported_mime_and_extension() -> None: + validate_upload_metadata("catch.jpeg", "image/jpeg") + validate_upload_metadata("catch.webp", "image/webp") + with pytest.raises(ScreenshotError, match="extension and MIME"): + validate_upload_metadata("catch.exe", "image/jpeg") + with pytest.raises(ScreenshotError, match="extension and MIME"): + validate_upload_metadata("catch.png", "image/jpeg") + with pytest.raises(ScreenshotError, match="extension and MIME"): + validate_upload_metadata("catch.jpg", "application/octet-stream") diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0881e43..d69d50c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -33,7 +33,7 @@ - [x] Показать скриншот, данные улова и причину решения; реализовать действия «одобрить» и «отклонить» (проверено в браузере на desktop и 390 px). - [x] Добавить удаление пользовательского сообщения администратором с аудитом действия (обезличивание записи, удаление объекта MinIO, миграция `0006`). - [x] Заменить in-memory rate limit на общее хранилище, пригодное для нескольких API-процессов и перезапусков (PostgreSQL, HMAC-отпечаток без хранения исходного IP, миграция `0007`). -- [ ] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа. +- [x] Валидировать одновременно содержимое, MIME, расширение и лимит изображения; добавить тесты каждого отказа. - [ ] Добавить сквозной тест: отправка → pending → модерация → появление одобренного улова в публичной статистике. - [ ] Добавить понятные состояния успеха и ошибок загрузки в форму, включая отдельную ошибку скриншота без потери уже созданной заявки.