Files
turnstone/tests/test_thumbnails.py
T
Patrick Buckley 25101e7ff6 fix(attachments): close thumbnail decompression-bomb gap (40M, not 80M)
make_thumbnail set Image.MAX_IMAGE_PIXELS=40M, but Pillow only raises DecompressionBombError above 2x the cap; a 40-80M px image merely warns and decodes fully (~480MB RGB), defeating the documented bound.

Gate on the header-declared size after open() and before convert(), so nothing past the cap is decoded. Explicit check rather than a warnings filter — make_thumbnail runs in a worker thread and global warnings state is not thread-safe. Adds tests for the (cap, 2*cap] warn-only window and the at-cap boundary.
2026-06-16 00:48:14 -07:00

81 lines
3.1 KiB
Python

"""Tests for attachment thumbnail generation (image downscale + pdf first page)."""
from __future__ import annotations
import pytest
from turnstone.core.thumbnails import make_thumbnail
PNG_1x1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
)
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _minimal_pdf(text: str = "Hi") -> bytes:
stream = b"BT /F1 24 Tf 20 60 Td (" + text.encode("latin-1") + b") Tj ET"
objs = [
b"<</Type/Catalog/Pages 2 0 R>>",
b"<</Type/Pages/Kids[3 0 R]/Count 1>>",
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>",
b"<</Length %d>>\nstream\n%s\nendstream" % (len(stream), stream),
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>",
]
pdf = b"%PDF-1.4\n"
offsets = []
for i, obj in enumerate(objs, 1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n%s\nendobj\n" % (i, obj)
xref = len(pdf)
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for off in offsets:
pdf += b"%010d 00000 n \n" % off
pdf += b"trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF" % (len(objs) + 1, xref)
return pdf
class TestMakeThumbnail:
def test_image_thumbnail_is_png(self) -> None:
out = make_thumbnail(PNG_1x1, "image")
assert out is not None and out[:8] == _PNG_MAGIC
def test_pdf_thumbnail_is_png(self) -> None:
out = make_thumbnail(_minimal_pdf(), "pdf")
assert out is not None and out[:8] == _PNG_MAGIC
def test_audio_has_no_thumbnail(self) -> None:
assert make_thumbnail(b"RIFFfake", "audio") is None
def test_garbage_image_returns_none(self) -> None:
assert make_thumbnail(b"not an image", "image") is None
@pytest.mark.filterwarnings("ignore::PIL.Image.DecompressionBombWarning")
def test_oversized_image_rejected(self, monkeypatch) -> None:
# An image past the pixel cap must be rejected WITHOUT decoding it. Use a
# size in the (cap, 2*cap] window — Pillow only *warns* there and would
# decode fully, so this guards the explicit size check, not Pillow's >2x
# raise.
from io import BytesIO
from PIL import Image
monkeypatch.setattr("turnstone.core.thumbnails._MAX_IMAGE_PIXELS", 50)
buf = BytesIO()
Image.new("RGB", (6, 10)).save(buf, format="PNG") # 60 px, in (50, 100]
assert make_thumbnail(buf.getvalue(), "image") is None
def test_at_cap_image_still_renders(self, monkeypatch) -> None:
# Exactly at the cap is allowed (boundary is strictly greater-than).
from io import BytesIO
from PIL import Image
monkeypatch.setattr("turnstone.core.thumbnails._MAX_IMAGE_PIXELS", 64)
buf = BytesIO()
Image.new("RGB", (8, 8)).save(buf, format="PNG") # 64 px == cap
out = make_thumbnail(buf.getvalue(), "image")
assert out is not None and out[:8] == _PNG_MAGIC