Repository: WordPress/openverse-api

Issue:
`piexif.dump` errors are not safely handled
## Sentry link

<!-- The public (aka "share") Sentry issue link. -->
https://sentry.io/share/issue/a80d52de7f89436586ed0250cd0a32d2/

## Description

<!-- Example: We are trying to access property foo of ImportantClass but the instance is null. -->
<!-- Include any additional information you may have, including potential remedies if any come to mind, and the general context of the code (what causes it to run in the app).  -->
The call to `piexif.dump` should be wrapped in a `try/except` to prevent these errors in the watermark endpoint.

<!-- Mention whether this is a known regression, i.e., the feature used to work and now does not.  -->

## Reproduction

<!-- Share the steps to reproduce the issue, if you were able to, OR a note sharing that you tried to reproduce but weren’t able to. -->
Visit https://api-dev.openverse.engineering/v1/images/a913fde1-d524-4059-bd4f-9bd687578cc3/watermark/ to see an example of this failure.


Discussion:
Closing this in favor of #827, as both Sentry errors are manifestations of the same issue. I'll also merge the Sentry issues together.
We're not catching all the exceptions on this code block so reopening.

https://github.com/WordPress/openverse-api/blob/6b7e963419d4894260934046c5421f9b932de743/api/catalog/api/utils/watermark.py#L157-L170

Resolving patch:
diff --git a/api/catalog/api/utils/watermark.py b/api/catalog/api/utils/watermark.py
--- a/api/catalog/api/utils/watermark.py
+++ b/api/catalog/api/utils/watermark.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import struct
 from enum import Flag, auto
 from io import BytesIO
 from textwrap import wrap
@@ -158,17 +159,18 @@ def _open_image(url):
         response = requests.get(url, headers=HEADERS)
         img_bytes = BytesIO(response.content)
         img = Image.open(img_bytes)
-        # Preserve EXIF metadata
-        if "exif" in img.info:
-            exif = piexif.load(img.info["exif"])
-        else:
-            exif = None
-        return img, exif
     except requests.exceptions.RequestException as e:
         capture_exception(e)
         logger.error(f"Error loading image data: {e}")
         return None, None
 
+    try:
+        # Preserve EXIF metadata
+        exif = piexif.load(img.info["exif"]) if "exif" in img.info else None
+        return img, exif
+    except struct.error:
+        return img, None
+
 
 def _print_attribution_on_image(img, image_info):
     """


Test patch:
diff --git a/api/test/unit/utils/watermark_test.py b/api/test/unit/utils/watermark_test.py
--- a/api/test/unit/utils/watermark_test.py
+++ b/api/test/unit/utils/watermark_test.py
@@ -1,12 +1,16 @@
 import json
+import struct
 from dataclasses import dataclass
+from io import BytesIO
 from pathlib import Path
 from typing import Callable
+from unittest import mock
 
 import pytest
+from PIL import Image
 from requests import Request, Response
 
-from catalog.api.utils.watermark import HEADERS, watermark
+from catalog.api.utils.watermark import HEADERS, _open_image, watermark
 
 
 _MOCK_IMAGE_PATH = Path(__file__).parent / ".." / ".." / "factory"
@@ -51,3 +55,19 @@ def test_sends_UA_header(requests):
     assert len(requests.requests) > 0
     for r in requests.requests:
         assert r.headers == HEADERS
+
+
+def test_catch_struct_errors_from_piexif(requests):
+    img_mock = Image.open(BytesIO(_MOCK_IMAGE_BYTES))
+    img_mock.info["exif"] = "bad_info"
+
+    with mock.patch("PIL.Image.open") as open_mock, mock.patch(
+        "piexif.load"
+    ) as load_mock:
+        open_mock.return_value = img_mock
+        load_mock.side_effect = struct.error("unpack requires a buffer of 2 bytes")
+
+        img, exif = _open_image("http://example.com/")
+
+    assert img is not None
+    assert exif is None