Django/Security afbeelding upload, zomaar accepteren kan server laten crashen: een perfecte gids voor beveiliging en efficiëntie
In webdiensten is afbeelding uploaden een "altijd aanwezige functie" en lijkt het makkelijk te implementeren. Maar het uploadend punt is de meest directe doorgang voor externe data naar de server en voor aanvallers de goedkoopste manier om maximale schade toe te brengen. (webshell upload, afbeelding parser kwetsbaarheden, DoS, etc.)
Deze tekst legt uit hoe je het ogenschijnlijk tegenstrijdige doel van "beveiliging obsessief, middelen economisch" beide behaalt. De kern is simpel.
- In de verificatie stap lees je zo min mogelijk (geen decodering, alleen header/metadata)
- In de opslag stap maak je opnieuw (Transcoding voor Sanitizing)
De essentie van upload beveiliging: "Vertrouw niet, wacht tot het nodig is"
Bij uploads kunnen we bijna niets vertrouwen.
- Bestandnaam: kan door de gebruiker gewijzigd worden
- Extensie: kan vrij gewijzigd worden
- Content-Type: wordt door de client meegegeven
- Bestandinhoud: wordt door de aanvaller gemaakt
Daarom convergeren de strategieën naar twee:
- Snel en goedkoop filteren (cheap checks)
- De definitieve versie wordt altijd door de server gemaakt (server-generated artifact)
Veelvoorkomende misvatting: "Om alles te controleren moet je alles lezen?"
Een veelgemaakte fout van ontwikkelaars die beveiliging serieus nemen is het toevoegen van code zoals hieronder in de verificatie stap.
img = Image.open(file)
img.verify() # of img.load()
Het probleem is dat dit de server middelen vooraf aan de aanvaller levert.
Waarom gevaarlijk?
- Compressie bom (Decompression Bomb)
Op het eerste gezicht een paar MB, maar na decodering kan het tientallen GB worden.
load()decodeert daadwerkelijk de pixels en kan geheugen/CPU in een oogwenk opruimen, wat leidt tot DoS. - Onnodige I/O
verify()leest het bestand tot het einde, wat I/O kost en vaak eenseek(0)of heropenen vereist.
Conclusie: In de upload "verificatie" stap decodeer je geen pixels. Header + metadata + resolutie beperking is voldoende voor een eerste verdedigingslaag.
Bescherming is geen enkele lijn: 3‑stappen Defense in Depth
Afbeelding uploaden kan niet met een simpele "if" line worden opgelost. Een realistische balans vereist lagen.
1. Extensies zijn leugens — MIME bepalen via Magic Number
Een bestand met de naam profile.png zegt niets. Je moet het signatuur (Magic Number) lezen om het echte type te bevestigen.
- Lees het hele bestand niet. Eerste 1–2KB volstaat.
- Voorbeeldbibliotheek:
python-magic(libmagic gebaseerd)
2. Pillow is "alleen openen" nog veilig — Lazy Loading voor resolutie beperking
Image.open() van Pillow laadt meestal niet meteen pixels maar alleen de header. Deze eigenschap kun je gebruiken om, voordat een decodering/ geheugen bom ontstaat, te blokkeren op resolutie (pixel aantal).
- Controleer:
width * height <= MAX_PIXELS - Punt: gebruik
sizezonderload()/verify()
3. De beste sanering is "opnieuw tekenen" — Transcoding voor Sanitizing
Dit is het belangrijkste principe.
Sla het origineel niet op.
Afbeeldingen kunnen metadata (EXIF), profiel, Slack ruimte, parser trucs, etc. bevatten in niet‑pixel gebieden. Door alleen pixel data te extraheren en de server een nieuwe afbeelding te laten maken, worden veel van deze verborgen zaken automatisch verwijderd.
- Aanbevolen: WebP (of AVIF/JPEG) om de server opnieuw te coderen en op te slaan
- Effect: Sanitizing + grootte optimalisatie + consistente formaatbeleid
Praktische implementatie: DRF Serializer (beveiliging + geheugen efficiëntie)
De onderstaande code bevat de filosofie "in de verificatie stap lees zo min mogelijk" en "in de opslag stap maak opnieuw".
f.sizeis metadata die Django al kent, dus benut het- Magic Number leest alleen het begin
- Pillow gebruikt
open()voor resolutie alleen - Definitieve opslag is Transcoding (WebP)
- Elke stap herstelt de file pointer met
seek(0)
from io import BytesIO
import magic
from PIL import Image, ImageOps, UnidentifiedImageError
from rest_framework import serializers
# Beleid (pas aan op basis van service)
MAX_SIZE = 5 * 1024 * 1024 # 5MB
MAX_PIXELS = 4_194_304 # 2048 * 2048 ≈ 4MP
ALLOWED_MIME = {"image/png", "image/jpeg", "image/webp"}
class SecureImageUploadSerializer(serializers.Serializer):
file = serializers.ImageField()
def validate_file(self, f):
# [1] Grootte beperking: goedkoopste en snelste filter
if f.size > MAX_SIZE:
raise serializers.ValidationError("Bestand is te groot.")
# [2] Magic Number voor MIME: vertrouw niet op extensie/Content-Type
f.seek(0)
head = f.read(2048) # alleen het begin
f.seek(0)
mime = magic.from_buffer(head, mime=True)
if mime not in ALLOWED_MIME:
raise serializers.ValidationError("Ondersteunde bestandsindeling niet gevonden.")
# [3] Resolutie beperking: zonder load/verify alleen header op basis van size
try:
with Image.open(f) as img:
w, h = img.size
if (w * h) > MAX_PIXELS:
raise serializers.ValidationError("Afbeelding resolutie is te groot.")
except UnidentifiedImageError:
raise serializers.ValidationError("Ongeldige afbeelding.")
except Exception:
raise serializers.ValidationError("Fout tijdens afbeelding verificatie.")
finally:
f.seek(0)
return f
def create(self, validated_data):
f = validated_data["file"]
# Definitieve versie wordt altijd door de server gemaakt (Sanitizing)
try:
with Image.open(f) as img:
# EXIF rotatie correctie (belangrijk bij mobiele uploads)
img = ImageOps.exif_transpose(img)
# Veilig en consistent kleur ruimte normaliseren
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGB")
out = BytesIO()
img.save(out, format="WEBP", quality=85, method=6)
out.seek(0)
safe_bytes = out.getvalue()
# Hier sla je safe_bytes op in je opslag
# - Naam randomiseren (UUID)
# - Directory sharding (bijv. ab/cd/uuid.webp)
# - In DB sla je alleen de server gegenereerde sleutel op
return safe_bytes
except Exception:
raise serializers.ValidationError("Fout tijdens afbeelding verwerking.")
"Obsessieve beveiliging" vs "middelen economisch" in balans brengen
De cruciale balans hier is:
In de verificatie stap niet te veel eisen
De verificatie stap is het meest beladen met verkeer en het gebied waar aanvallers zonder kosten herhaaldelijk kunnen aanroepen. Als je hier een "duurzame operatie" zoals load() uitvoert, kunnen aanvallers de server kosten laten oplopen.
- ✅ Grootte beperking / header MIME / resolutie beperking
- ❌ Pixel decodering forceren / volledige bestand lezen / meerdere heropenen
De echte veiligheid ontstaat in de opslag stap
Verificatie is een "filteren" stap, opslag is een "standariseren" stap. Als de server een nieuwe byte stream genereert, wordt beveiliging en beheer eenvoudiger.
- Formaat uniform → vereenvoudigt cache strategie / thumbnail pipeline
- Metadata opruimen → helpt bij het verwijderen van persoonlijke gegevens (EXIF GPS)
- Vermindert kans op kwaadaardige payloads
Als je deze punten volgt, is het nog beter!

- Bestandsnaam vertrouw je nooit, maak een server‑gegenereerde naam (UUID aanbevolen)
- Overweeg een architectuur waarbij de app server de upload niet direct ontvangt (bij grote services direct upload naar object storage via presigned URL + asynchrone verificatie/transformatie)
- Verwerk tijdslimiet/worker isolatie Afbeelding transformatie gebruikt CPU. Houd de web request‑response route kort en verplaats naar een worker/queue.
- Log/metrics Verwijderingsreden (MIME mismatch, resolutie overschrijding, grootte overschrijding) tellen geeft snel inzicht in aanvalspatronen.
Samenvattende checklist
- Laad het volledige bestand niet in geheugen.
read()alleen het begin, de rest streaming/ bestand object. - Vertrouw niet op extensie/Content-Type, gebruik Magic Number voor MIME
- In de verificatie stap decodeer je geen pixels, controleer alleen resolutie
- Sla het origineel niet op, maak een nieuwe afbeelding via Transcoding
- Herstel de file pointer met
seek(0)na elke stap
Upload beveiliging begint met een sceptische houding tegenover gebruikers, maar upload prestaties worden bereikt door een goed begrip van hoe het systeem werkt. Beide aspecten moeten samenkomen om een veilige upload poort te creëren.
Bekijk ook gerelateerde posts
댓글이 없습니다.