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:
parent
34f2ca6f84
commit
78a53cb87c
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue