Sfoglia il codice sorgente

Security: harden ImageAttachment uploads against polyglot XSS

Re-encode uploaded images through Pillow before storage. Pillow's
image.save() produces clean output containing only the image data; any
non-image trailer bytes embedded by a polyglot payload are stripped. An
explicit extension check against IMAGE_ATTACHMENT_IMAGE_FORMATS is also
added so the API path cannot bypass the form-level accept= filter.

Note: X-Content-Type-Options: nosniff and Content-Disposition: attachment
are already provided by the MediaView hardening in #22400 (commit 87c53aa).
This commit provides the complementary upload-time defence.

Pillow is already a required dependency (ImageField uses it for dimension
extraction), so the top-level import adds no new dependency.

Ref: SR-001 / VM-326 (internal security review)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brian Tiemann 3 settimane fa
parent
commit
78a53cb87c
1 ha cambiato i file con 28 aggiunte e 0 eliminazioni
  1. 28 0
      netbox/extras/models/models.py

+ 28 - 0
netbox/extras/models/models.py

@@ -1,7 +1,10 @@
+import io
 import json
 import urllib.parse
 from pathlib import Path
 
+from PIL import Image as PillowImage
+
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.postgres.fields import ArrayField
@@ -741,6 +744,31 @@ class ImageAttachment(ChangeLoggedModel):
                 _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.object_type)
             )
 
+        # Re-encode the image through Pillow to strip any embedded non-image
+        # payloads (e.g. polyglot HTML/PNG files). This must be done after the
+        # ImageField's own Pillow dimension check has already run.
+        if self.image and hasattr(self.image, 'file'):
+            try:
+                self.image.file.seek(0)
+                img = PillowImage.open(self.image.file)
+                img_format = img.format or 'PNG'
+                # Validate extension against allowed formats
+                ext = Path(self.image.name).suffix.lstrip('.').lower()
+                if ext not in IMAGE_ATTACHMENT_IMAGE_FORMATS:
+                    raise ValidationError(
+                        _("Unsupported image format: {ext}").format(ext=ext)
+                    )
+                # Re-encode to a new buffer; this strips any non-image trailer data
+                buf = io.BytesIO()
+                img.save(buf, format=img_format)
+                buf.seek(0)
+                self.image.file = buf
+                self.image.file.seek(0)
+            except ValidationError:
+                raise
+            except Exception:
+                raise ValidationError(_("Unable to process the uploaded image."))
+
     def delete(self, *args, **kwargs):
 
         _name = self.image.name