From 05e33a87a9c85a1f11c7e3904d21db46de296024 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 5 Aug 2026 15:56:09 +0200 Subject: [PATCH] fix: resize oversized image attachments during import --- README.md | 6 +- netbox_export/__init__.py | 2 +- netbox_export/services/exporter.py | 2 +- netbox_export/services/importer.py | 129 +++++++++++++++++++++++++---- pyproject.toml | 2 +- tests/test_file_import.py | 40 +++++++++ 6 files changed, 163 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f8de105..be1706b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,11 @@ meldet den Rackplatzkonflikt vor dem Datenbankfehler. Mehr-U- und Full-Depth-Belegungen werden dabei berücksichtigt. Bei Bildanhängen werden Breite und Höhe direkt aus der Bilddatei im Archiv ermittelt. Dadurch sind die Pflichtfelder von NetBox auch im Prüflauf und bei -Dateispeichern ohne unmittelbaren Modell-Save gesetzt. +Dateispeichern ohne unmittelbaren Modell-Save gesetzt. Bilder oberhalb des in +NetBox 4.6.x verwendeten Limits von 25 Millionen Pixeln werden proportional auf +höchstens 20 Millionen Pixel verkleinert und im Importbericht als Warnung +ausgewiesen. Zum Schutz des Importprozesses bleibt eine harte Quellgrenze von +100 Millionen Pixeln bestehen. Auf Quelle und Ziel müssen jeweils dieselben Plugin-Versionen und Migrationen installiert sein. Verschlüsselte Zugangsdaten von NetBox-VM-Import sind nur bei diff --git a/netbox_export/__init__.py b/netbox_export/__init__.py index ef8f098..c24158e 100644 --- a/netbox_export/__init__.py +++ b/netbox_export/__init__.py @@ -7,7 +7,7 @@ class NetBoxExportConfig(PluginConfig): name = "netbox_export" verbose_name = "NetBox-Export" description = "Portable ZIP export and import for tenants and locations" - version = "0.3.10" + version = "0.3.11" author = "NetBox Export contributors" base_url = "netbox-export" min_version = "4.6.0" diff --git a/netbox_export/services/exporter.py b/netbox_export/services/exporter.py index 02a5eb0..1a00334 100644 --- a/netbox_export/services/exporter.py +++ b/netbox_export/services/exporter.py @@ -39,7 +39,7 @@ def export_scope( "created_at": datetime.now(UTC).isoformat(), "source_instance": str(InstanceIdentity.local_id()), "source_netbox_version": getattr(getattr(settings, "RELEASE", None), "version", "4.6"), - "plugin_version": "0.3.10", + "plugin_version": "0.3.11", "scope": { "type": scope_type, "source_pk": str(scope_id), diff --git a/netbox_export/services/importer.py b/netbox_export/services/importer.py index 4a7a49c..c07878f 100644 --- a/netbox_export/services/importer.py +++ b/netbox_export/services/importer.py @@ -1,7 +1,11 @@ from __future__ import annotations +import io import logging +import math +import threading import uuid +import warnings from dataclasses import dataclass from dataclasses import field as dataclass_field from decimal import Decimal @@ -10,8 +14,8 @@ from django.apps import apps from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.files.base import ContentFile -from django.core.files.images import get_image_dimensions from django.db import IntegrityError, models, transaction +from PIL import Image as PillowImage from netbox_export.models import ImportedObjectMapping @@ -53,6 +57,11 @@ EXPLICIT_IDENTITIES = { "virtualization.vminterface": ("virtual_machine", "name"), } +NETBOX_IMAGE_MAX_PIXELS = 25_000_000 +IMPORTED_IMAGE_TARGET_PIXELS = 20_000_000 +IMPORTED_IMAGE_SOURCE_MAX_PIXELS = 100_000_000 +_IMAGE_LIMIT_LOCK = threading.Lock() + class IdentityNotReady(Exception): pass @@ -462,33 +471,118 @@ def _set_generic_relations(obj, record, resolver, *, allow_deferred: bool): return unresolved, missing_required -def _set_files(obj, record, assets, saved_files, *, dry_run: bool): +def _open_image_with_bounded_override(data): + stream = io.BytesIO(data) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", PillowImage.DecompressionBombWarning) + image = PillowImage.open(stream) + return stream, image + except (PillowImage.DecompressionBombError, PillowImage.DecompressionBombWarning): + stream.close() + except Exception: + stream.close() + raise + + stream = io.BytesIO(data) + with _IMAGE_LIMIT_LOCK: + original_limit = PillowImage.MAX_IMAGE_PIXELS + PillowImage.MAX_IMAGE_PIXELS = IMPORTED_IMAGE_SOURCE_MAX_PIXELS + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", PillowImage.DecompressionBombWarning) + image = PillowImage.open(stream) + except PillowImage.DecompressionBombError as exc: + stream.close() + raise ArchiveValidationError( + "Das Bild überschreitet die sichere Importgrenze von " + f"{IMPORTED_IMAGE_SOURCE_MAX_PIXELS:,} Pixeln." + ) from exc + except Exception: + stream.close() + raise + finally: + PillowImage.MAX_IMAGE_PIXELS = original_limit + return stream, image + + +def _resized_image_save_options(image_format, image): + options = {} + if icc_profile := image.info.get("icc_profile"): + options["icc_profile"] = icc_profile + if image_format in ("JPEG", "MPO"): + options.update(quality=85, optimize=True, progressive=True) + elif image_format == "WEBP": + options.update(quality=85, method=4) + elif image_format in ("PNG", "GIF"): + options["optimize"] = True + elif image_format == "TIFF": + options["compression"] = "tiff_deflate" + return options + + +def _prepare_image_asset(data): + stream, image = _open_image_with_bounded_override(data) + try: + width, height = image.size + source_pixels = width * height + if source_pixels > IMPORTED_IMAGE_SOURCE_MAX_PIXELS: + raise ArchiveValidationError( + f"Das Bild mit {source_pixels:,} Pixeln überschreitet die sichere Importgrenze von " + f"{IMPORTED_IMAGE_SOURCE_MAX_PIXELS:,} Pixeln." + ) + if source_pixels <= NETBOX_IMAGE_MAX_PIXELS: + return data, width, height, None + + scale = math.sqrt(IMPORTED_IMAGE_TARGET_PIXELS / source_pixels) + target_size = (max(1, math.floor(width * scale)), max(1, math.floor(height * scale))) + image.thumbnail(target_size, PillowImage.Resampling.LANCZOS, reducing_gap=3.0) + image_format = image.format or "PNG" + if image_format == "MPO": + image_format = "JPEG" + output = io.BytesIO() + image.save(output, format=image_format, **_resized_image_save_options(image_format, image)) + resized_width, resized_height = image.size + resize_info = (width, height, resized_width, resized_height) + return output.getvalue(), resized_width, resized_height, resize_info + finally: + image.close() + stream.close() + + +def _set_files(obj, record, assets, saved_files, *, dry_run: bool, import_warnings=None): for name, spec in record.get("files", {}).items(): if not spec or "path" not in spec or spec["path"] not in assets: continue filename = spec.get("name", spec["path"]).replace("\\", "/").rsplit("/", 1)[-1] field = obj._meta.get_field(name) - content = ContentFile(assets[spec["path"]]) - dimensions = None + content_data = assets[spec["path"]] + dimensions = (None, None) if isinstance(field, models.ImageField): - dimensions = get_image_dimensions(content) - content.seek(0) - width, height = dimensions + content_data, width, height, resize_info = _prepare_image_asset(content_data) + dimensions = (width, height) if field.width_field and width is not None: setattr(obj, field.width_field, width) if field.height_field and height is not None: setattr(obj, field.height_field, height) + if resize_info is not None and import_warnings is not None: + old_width, old_height, new_width, new_height = resize_info + record_id = record.get("id", obj._meta.label_lower) + import_warnings.append( + f"Bild für {record_id} wurde von {old_width}×{old_height} auf " + f"{new_width}×{new_height} Pixel verkleinert." + ) if dry_run: continue + content = ContentFile(content_data) file_value = getattr(obj, name) file_value.save(filename, content, save=False) saved_files.append((file_value.storage, file_value.name)) - if dimensions is not None: - width, height = dimensions - if field.width_field and width is not None: - setattr(obj, field.width_field, width) - if field.height_field and height is not None: - setattr(obj, field.height_field, height) + width, height = dimensions + if isinstance(field, models.ImageField) and field.width_field and width is not None: + setattr(obj, field.width_field, width) + if isinstance(field, models.ImageField) and field.height_field and height is not None: + setattr(obj, field.height_field, height) def _cleanup_files(saved_files): @@ -594,7 +688,14 @@ def import_archive(parsed: ParsedArchive, *, conflict_strategy: str, dry_run: bo pending.pop(record_id) progressed = True continue - _set_files(obj, record, parsed.assets, saved_files, dry_run=dry_run) + _set_files( + obj, + record, + parsed.assets, + saved_files, + dry_run=dry_run, + import_warnings=report.warnings, + ) if device_placement is not None: _stage_device_placement(obj) compatibility.prepare_initial_save(obj, is_new=existing is None) diff --git a/pyproject.toml b/pyproject.toml index dce7b01..8797074 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-export" -version = "0.3.10" +version = "0.3.11" description = "Portable ZIP export and import for scoped NetBox data" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_file_import.py b/tests/test_file_import.py index b97aab6..9703275 100644 --- a/tests/test_file_import.py +++ b/tests/test_file_import.py @@ -4,6 +4,7 @@ from django.core.files.storage import InMemoryStorage from django.db import models from PIL import Image +from netbox_export.services import importer as importer_module from netbox_export.services.importer import _cleanup_files, _set_files @@ -65,3 +66,42 @@ def test_file_import_keeps_derived_dimensions_after_storage_save(monkeypatch): _cleanup_files(saved_files) assert not storage.exists(obj.image.name) + + +def test_oversized_image_is_resized_after_pillow_bomb_error(monkeypatch): + monkeypatch.setattr(importer_module, "NETBOX_IMAGE_MAX_PIXELS", 50) + monkeypatch.setattr(importer_module, "IMPORTED_IMAGE_TARGET_PIXELS", 40) + monkeypatch.setattr(importer_module, "IMPORTED_IMAGE_SOURCE_MAX_PIXELS", 200) + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 50) + field = ImageAsset._meta.get_field("image") + storage = InMemoryStorage() + monkeypatch.setattr(field, "storage", storage) + obj = ImageAsset() + record = image_record() + record["id"] = "extras.imageattachment:17" + assets = {"assets/extras.imageattachment_17/image/server-room.png": image_bytes(11, 10)} + import_warnings = [] + saved_files = [] + + _set_files( + obj, + record, + assets, + saved_files, + dry_run=False, + import_warnings=import_warnings, + ) + + assert obj.image_width * obj.image_height <= 40 + with storage.open(obj.image.name, "rb") as stored_file, Image.open(stored_file) as stored_image: + assert stored_image.size == (obj.image_width, obj.image_height) + assert stored_image.format == "PNG" + assert import_warnings == [ + ( + f"Bild für extras.imageattachment:17 wurde von 11×10 auf " + f"{obj.image_width}×{obj.image_height} Pixel verkleinert." + ) + ] + assert Image.MAX_IMAGE_PIXELS == 50 + + _cleanup_files(saved_files)