On November 4, the Rails team disclosed a critical flaw in Active Storage (GHSA-f3w6-w445-fwh9, CVSS 9.8, no CVE yet at the time of writing). An unauthenticated attacker who can upload a file may be able to write arbitrary files on the server, and if attachments live on the Disk service, read any file the app process can read. That usually means secret_key_base and credentials for other systems.
If you write Django, it is tempting to close the tab. Don't, yet. The Rails advisory is careful about where the bug actually lives, and that place is not Rails-specific.
What the advisory says
ImageMagick picks a decoder, which it calls a coder, from the content of the file it is given. Some coders do more than decode: they act on other files named inside the image. Active Storage did not constrain any of them, and its ImageMagick analyzer runs the moment a file is attached. In the advisory's words, attaching the upload is sufficient; no variant, preview or analyze call is needed. The reported chain used an uploaded SVG that reached ImageMagick's MSL scripting coder.
The Rails maintainers also say they "do not assume" that chain is the only one.
An app is affected only if all three of these are true:
- It uses ImageMagick for Active Storage (
variant_processor = :mini_magick, the default for apps onload_defaults6.1 or earlier). - The ImageMagick policy on the host does not already deny the coders involved. The advisory says the policies Debian and Ubuntu package do not.
- It accepts uploads from untrusted users.
The fixes are in Rails 8.1.4.1 and 8.0.5.2, and they ship an ImageMagick policy that denies MSL, the Ghostscript-backed coders, the gs delegate and @filename indirection. Per The Hacker News' write-up, Rails 8.0 only gets security fixes until November 7, 2026, and series older than 8.0 get no fix at all.
I'm not going to repeat the exploit details. The point for the rest of us is the shape of the bug: a file's content decided which code processed it, and the host's policy file was the only guardrail.
Why this matters if you don't run Rails
Django's ImageField uses Pillow, and Pillow is a separate library that does not call ImageMagick. So a stock Django app that only validates uploads through ImageField is not in the Rails bug's path. I want to be clear about that, because "Django is also vulnerable" would be false.
The risk is in what Django projects add around that default. In Django projects, the ImageMagick dependency tends to arrive through one of these:
- a
subprocesscall toconvertormagickfor resizing, watermarking or PDF thumbnails - the Wand library, which is a binding for ImageMagick
- a thumbnail library configured to use an ImageMagick engine (sorl-thumbnail documents a
convertengine for this) - a worker image or Dockerfile that has
imagemagickinstalled because some earlier feature needed it
Each of those hands the raw upload to a tool that chooses its decoder by sniffing content, running with whatever policy.xml the base image shipped. That is the same arrangement the Rails advisory describes.
Step 1: find out whether you have it
Search your repos and images before you read anything else:
grep -rniE "magick|\bconvert\b|wand|ghostscript|\bgs\b" --include=*.py --include=Dockerfile* --include=*.txt --include=*.toml . Then, on a running container or host that has ImageMagick, print the policy that is really in effect:
magick -list policy # ImageMagick 7 identify -list policy # ImageMagick 6 If the output has no rules for the MSL coder, the Ghostscript coders or the gs delegate, that host is relying on defaults. A wildcard allow rule such as pattern="*" is also worth a hard look: the Rails notes warn that a rule allowing a coder, including a wildcard, overrides a block on that coder.
Step 2: deny the dangerous coders in the system policy
You can't patch Rails if you don't run it, but you can copy the defensive idea. The advisory's workaround for people who can't upgrade is to put at least these rules into ImageMagick's system policy, for example /etc/ImageMagick-7/policy.xml:
<policy domain="coder" rights="none" pattern="MSL"/> <policy domain="delegate" rights="none" pattern="gs"/> <policy domain="coder" rights="none" pattern="{PS,PS2,PS3,EPS,EPS2,EPS3,EPI,EPSF,EPSI,EPT,EPT2,EPT3,PDF,AI,EPDF,PDFA,POCKETMOD,XPS,PCL}"/> If you really do render PDFs through ImageMagick, you'll need to re-allow the Ghostscript pieces you depend on, and you should make that a conscious decision rather than a default. Note that these rules go in the host's file; they are a minimum, not the full policy Rails ships. I have not run ImageMagick on my own machine for this post, so test the policy against your real images before you roll it out.
Step 3: stop sending raw uploads to anything clever
The better fix is to make sure untrusted bytes never reach a content-sniffing tool at all. Decode them with a narrow allowlist, then re-encode, and only ever hand the clean copy to later processing.
Here is a small sanitizer using Pillow. It checks the real format from the bytes (not the filename or the Content-Type header), refuses anything but three formats, caps the size and pixel count, re-encodes the image, and picks a new name with a server-chosen extension:
# uploads/safe_upload.py import io import secrets from PIL import Image, UnidentifiedImageError ALLOWED = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"} FORMATS = list(ALLOWED) # only these decoders are ever tried MAX_BYTES = 5 * 1024 * 1024 MAX_PIXELS = 24_000_000 Image.MAX_IMAGE_PIXELS = MAX_PIXELS class RejectedUpload(ValueError): pass def sanitize_image(data: bytes) -> tuple[bytes, str]: if len(data) > MAX_BYTES: raise RejectedUpload("too large") try: probe = Image.open(io.BytesIO(data), formats=FORMATS) fmt = probe.format probe.verify() except (UnidentifiedImageError, Image.DecompressionBombError, OSError) as exc: raise RejectedUpload(f"not a supported image: {type(exc).__name__}") from exc if fmt not in ALLOWED: raise RejectedUpload(f"format {fmt} not allowed") img = Image.open(io.BytesIO(data), formats=FORMATS) # verify() invalidates the first object if img.width * img.height > MAX_PIXELS: raise RejectedUpload("too many pixels") img = img.convert("RGB" if fmt == "JPEG" else "RGBA") out = io.BytesIO() img.save(out, format=fmt) # re-encode: trailing bytes and metadata are gone return out.getvalue(), f"{secrets.token_hex(16)}.{ALLOWED[fmt]}" The formats= argument matters. Without it, Pillow will try every decoder it knows, and some of those, such as its EPS support, can call Ghostscript. Restricting it means a PostScript file is rejected before any of that is considered.
Wiring it into a Django form takes a few lines:
from django import forms from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from .safe_upload import RejectedUpload, sanitize_image class AvatarForm(forms.Form): image = forms.FileField() def clean_image(self): upload = self.cleaned_data["image"] try: clean, name = sanitize_image(upload.read()) except RejectedUpload as exc: raise ValidationError(str(exc)) return ContentFile(clean, name=name) What I actually ran
I tested the sanitizer with Python 3.13, Pillow 13.0.0 and Django 6.1.3 on seven inputs. These are the results:
- A real PNG uploaded under a
.jpgfilename was accepted and saved as<random>.png. - An SVG whose
<image>pointed at/etc/passwdwas rejected as an unsupported image. - A PNG with a PHP snippet appended to the end was accepted, but the re-encoded output no longer contained the snippet.
- Random bytes were rejected.
- A file starting with a PostScript EPS header was rejected.
- A 6000x6000 PNG was rejected for exceeding the pixel cap.
- A 5000x4000 PNG under the cap was accepted.
Through a Django form, a PNG sent as evil.svg came out as a clean PNG with a random name, and an SVG sent as pic.png failed validation. That is a unit test of my own code, not a security audit of yours, and I haven't tried to attack it with a real exploit. Treat it as a starting point and add your own cases.
The second Rails bug is about filenames
The same Rails release fixes another flaw (GHSA-v924-7rp8-4mxh, rated 8.8): uploads were passed to ImageMagick and FFmpeg under temporary names that kept the uploaded file's extension, and those tools can choose a decoder from the extension when the content doesn't reveal its format. So whoever chose the filename and declared content type could influence which decoder ran.
The lesson carries over directly: never reuse a client's filename or extension for a temp file. The sanitizer above generates the name and the extension itself, which is the same idea.
A short checklist
- Does anything in your stack call ImageMagick, Ghostscript or FFmpeg on user uploads?
- Does the policy on the host or base image deny
MSL, the Ghostscript coders and thegsdelegate? - Are untrusted bytes decoded by an allowlist of formats first and re-encoded before further processing?
- Do temp files get a server-generated name and extension?
- If you run Rails somewhere too: are you on 8.1.4.1 or 8.0.5.2, with mini_magick 5.1 or later, and have you rotated secrets if you use the Disk service?
Over to you
Does your Django app re-encode every upload with Pillow before anything else touches it, or does the original file get handed to ImageMagick, Ghostscript or FFmpeg somewhere down the line? And if it's the second, who on your team knows what the policy.xml in your base image says?
