From 2bb6352141784ecdceb18d81456a1d51a4212cb5 Mon Sep 17 00:00:00 2001 From: pax Date: Sat, 11 Apr 2026 16:21:32 -0500 Subject: [PATCH] =?UTF-8?q?security:=20fix=20#8=20=E2=80=94=20install=20MA?= =?UTF-8?q?X=5FIMAGE=5FPIXELS=20cap=20in=20core/=5F=5Finit=5F=5F.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PIL's decompression-bomb cap previously lived as a side effect of importing core/cache.py. Any future code path that touched core/images (or any other core submodule) without first importing cache would silently revert to PIL's default 89M-pixel *warning* (not an error), re-opening the bomb surface. Moves the cap into core/__init__.py so any import of any booru_viewer.core.* submodule installs it first. The duplicate set in cache.py is left in place by this commit and removed in the next one — both writes are idempotent so this commit is bisect-safe. Audit-Ref: SECURITY_AUDIT.md finding #8 Severity: Low --- booru_viewer/core/__init__.py | 19 ++++++++++++ tests/core/test_pil_safety.py | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/core/test_pil_safety.py diff --git a/booru_viewer/core/__init__.py b/booru_viewer/core/__init__.py index e69de29..ee6eb2d 100644 --- a/booru_viewer/core/__init__.py +++ b/booru_viewer/core/__init__.py @@ -0,0 +1,19 @@ +"""booru_viewer.core package — pure-Python data + I/O layer (no Qt). + +Side effect on import: install the project-wide PIL decompression-bomb +cap. PIL's default warns silently above ~89M pixels; we want a hard +fail above 256M pixels so DecompressionBombError can be caught and +treated as a download failure. + +Setting it here (rather than as a side effect of importing +``core.cache``) means any code path that touches PIL via any +``booru_viewer.core.*`` submodule gets the cap installed first — +``core.images`` no longer depends on ``core.cache`` having been +imported in the right order. Audit finding #8. +""" + +from PIL import Image as _PILImage + +_PILImage.MAX_IMAGE_PIXELS = 256 * 1024 * 1024 + +del _PILImage diff --git a/tests/core/test_pil_safety.py b/tests/core/test_pil_safety.py new file mode 100644 index 0000000..6ac135c --- /dev/null +++ b/tests/core/test_pil_safety.py @@ -0,0 +1,57 @@ +"""Tests for the project-wide PIL decompression-bomb cap (audit #8). + +The cap lives in `booru_viewer/core/__init__.py` so any import of +any `booru_viewer.core.*` submodule installs it first — independent +of whether `core.cache` is on the import path. Both checks are run +in a fresh subprocess so the assertion isn't masked by some other +test's previous import. +""" + +from __future__ import annotations + +import subprocess +import sys + +EXPECTED = 256 * 1024 * 1024 + + +def _run(code: str) -> str: + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def test_core_package_import_installs_cap(): + """Importing the core package alone must set MAX_IMAGE_PIXELS.""" + out = _run( + "import booru_viewer.core; " + "from PIL import Image; " + "print(Image.MAX_IMAGE_PIXELS)" + ) + assert int(out) == EXPECTED + + +def test_core_images_import_installs_cap(): + """The original audit concern: importing core.images without first + importing core.cache must still set the cap.""" + out = _run( + "from booru_viewer.core import images; " + "from PIL import Image; " + "print(Image.MAX_IMAGE_PIXELS)" + ) + assert int(out) == EXPECTED + + +def test_core_cache_import_still_installs_cap(): + """Regression: the old code path (importing cache first) must keep + working after the move.""" + out = _run( + "from booru_viewer.core import cache; " + "from PIL import Image; " + "print(Image.MAX_IMAGE_PIXELS)" + ) + assert int(out) == EXPECTED