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>
This commit is contained in:
Brian Tiemann 2026-06-10 09:14:39 -04:00
parent 34f2ca6f84
commit 78a53cb87c
1 changed files with 28 additions and 0 deletions

View File

@ -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