skip to content

Why should a Django site never serve user uploads as trusted content from its own domain, and how do you serve MEDIA files safely?

level: seniorimportance: should knowfreq 45%

answer

  1. a valid image can be HTML
  2. same origin means same privileges
  3. subdomain is not enough
  4. size limits before Django
  5. private files need a gate

basics

~20 s

An upload can pass Django's ImageField check yet be interpreted as HTML or script; served from the site's own origin it runs with the site's privileges. Serve MEDIA_URL from a separate registrable domain, allowlist types and limit size.

solid answer

~50 s

Django's security docs warn that a file with a valid PNG header followed by HTML passes the Pillow-based `ImageField` validation, and depending on how the server serves it, a browser may render it as HTML. If that happens on the application's own origin, the script can read pages and act as the logged-in user. The documented mitigation is to serve uploads from a distinct top-level or second-level domain; a subdomain of the site is not enough. Point `MEDIA_URL`, or the storage's `url()`, at that domain. On top of that: allowlist extensions and have the server serve only those, send `X-Content-Type-Options: nosniff` and a correct `Content-Type`, prefer `Content-Disposition: attachment` for downloads, limit request size in the web server, and never let the server execute uploaded files. Private uploads go through a permission-checking view or short-lived signed URLs.

code

python · 16 lines
python
from django.core.validators import FileExtensionValidator
from django.db import models

ALLOWED = ['jpg', 'jpeg', 'png', 'webp']


class Profile(models.Model):
    avatar = models.ImageField(
        upload_to='avatars/',
        validators=[FileExtensionValidator(allowed_extensions=ALLOWED)],
        blank=True,
    )


# settings.py: uploads served from a separate registrable domain
MEDIA_URL = 'https://usercontent-example.com/media/'

go deeper

for a junior

Recall that uploads are untrusted, that Django does not serve media in production, and that a passing ImageField check does not make a file safe.

for a middle

Explain the same-origin risk of serving uploads from the application's domain and the headers and allowlists that reduce it.

for a senior

Set up MEDIA_URL on a separate registrable domain, size limits at the server, and signed URLs or a permission view for private files.

for a principal

Define a user-content policy across products: which domain serves it, how files are re-encoded or scanned, and who owns access decisions.

## The threat **User uploads are attacker-controlled bytes.** Django's own security documentation gives the canonical example: a file that begins with a valid PNG header and continues with malicious HTML passes the image verification that `ImageField` performs with Pillow. When that file is later served, the server's configuration and the browser's content sniffing may cause it to be displayed as HTML. Other routes to the same outcome are SVG images, which can contain script, and HTML files uploaded through a plain `FileField`. If the file is served from the **same origin** as the application, any script inside it runs with the application's privileges: it can read pages the victim can see and send requests carrying their session. That is why the question is not only "is this a real image" but "where is it served from". ## What Django does and does not do | Django provides | Django does not provide | |---|---| | `ImageField` checks the file opens as an image | proof the bytes are harmless | | `validate_file_name()` and `SuspiciousFileOperation` against path traversal | content-type or extension allowlists | | `get_valid_name()` sanitising file names | safe serving in production | | `FILE_UPLOAD_PERMISSIONS = 0o644` for new files | protection from a server that executes files | The docs are explicit that no bulletproof framework-level check can validate all uploaded content, so the defence is in how files are stored and served. ## Serving uploads safely 1. **Use a separate domain.** Serve `MEDIA_URL` (or make the storage's `url()` return links) on a distinct top-level or second-level domain, such as `usercontent-example.com` for a site on `example.com`. The docs stress that a subdomain like `usercontent.example.com` is not sufficient. 2. **Allowlist file types.** Validate extensions and content in the form or model, and configure the server or object store to serve only those types. 3. **Send honest headers.** A correct `Content-Type`, `X-Content-Type-Options: nosniff`, and `Content-Disposition: attachment` for anything that should be downloaded rather than displayed. 4. **Never execute uploads.** Handlers that run files as code must be disabled for the media location. 5. **Limit size before Django.** The docs advise limiting request bodies in the web server and not relying solely on `DATA_UPLOAD_MAX_MEMORY_SIZE` or `FILE_UPLOAD_MAX_MEMORY_SIZE`. 6. **Do not use development serving in production.** The `static()` URL helper and `django.views.static.serve` are for `DEBUG` only. ## Private uploads Public avatars can be served directly from the user-content domain. Files that only certain users may see need a gate: - A Django view checks permissions and then either streams the file or hands off to the web server or object store. - Or the storage's `url()` returns a **short-lived signed URL**, generated only after the permission check, so the object store serves the bytes without the Django process streaming them. Unguessable names from an `upload_to` callable (a UUID rather than the original file name) are useful defence in depth but are **not** access control. ## Avatars in object storage For the common case of profile images in object storage: a random `upload_to` name, an allowlisted image type, re-encoding the image server side if you want to strip anything that is not pixels, a public bucket or prefix served from the separate user-content domain, and `url()` producing links on that domain. The application domain never serves the raw bytes.

  • Why is a subdomain like usercontent.example.com not enough for uploads of a site on example.com?
    Django's security docs call for a distinct top-level or second-level domain. A sibling subdomain shares the registrable domain, so cookies scoped to the parent domain can still be sent to it and some browser protections treat it as same-site, leaving room for attacks that a separate domain prevents.
  • How do you let only a document's owner download it when files live in object storage?
    Keep the objects private and route access through a Django view that checks the permission. After the check, either stream the file from storage or redirect to a short-lived signed URL produced by the backend's `url()`. Unguessable file names help, but they are not a substitute for that check.

saying these in an interview costs you the question

  • ImageField validation guarantees an upload is a harmless image.
  • Serving uploads from usercontent.example.com is as safe as a separate domain.
  • Random UUID file names are enough access control for private files.
  • DATA_UPLOAD_MAX_MEMORY_SIZE alone protects against oversized file uploads.
  • django.views.static.serve is fine for media in production behind HTTPS.