From 78a53cb87cc26684f3d64261be1f73ef4a5fa94e Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 10 Jun 2026 09:14:39 -0400 Subject: [PATCH] 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 --- netbox/extras/models/models.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d538f94bf..8cc6a2ef4 100644 --- a/netbox/extras/models/models.py +++ b/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