Veilige bestandsuploads in webapps vereisen strikte permissies, groottebeperkingen, ondertekende URL's en eenvoudige malware-scans om incidenten te voorkomen.

Content-Type header van de client. Beide zijn makkelijk te vervalsen. Een bestand invoice.pdf kan een uitvoerbaar bestand zijn, en Content-Type: image/png kan een leugen zijn.\n\nEen sterkere aanpak is het inspecteren van de eerste bytes van het bestand, vaak “magic bytes” of een bestands-signatuur genoemd. Veel gangbare formaten hebben consistente headers (zoals PNG en JPEG). Als de header niet overeenkomt met wat je toestaat, weiger het.\n\nEen praktische validatie-opzet:\n\n- Allowlist de extensies die je accepteert (server-side lijst).\n- Detecteer het MIME-type op de server (niet de client-header).\n- Sniff magic bytes voor de formaten die je ondersteunt.\n- Genereer een nieuwe willekeurige opslagnaam en bewaar de originele naam als metadata.\n- Blokkeer risicovolle formaten tenzij je ze echt nodig hebt, vooral HTML, SVG en script-achtige content.\n\nHernoemen is belangrijker dan het lijkt. Als je gebruikersnamen direct opslaat, nodig je padtrucs, vreemde tekens en per ongeluk overschrijven uit. Gebruik een gegenereerd ID voor opslag en bewaar de originele bestandsnaam alleen voor weergave.\n\nVoor profielfoto's accepteer alleen JPEG en PNG, controleer headers en verwijder metadata als dat kan. Voor documenten overweeg je alleen PDF toe te staan en alles met actieve content te weigeren. Als je later SVG of HTML nodig hebt, behandel ze dan als potentieel uitvoerbaar en isoleer ze.\n\n## Groottebeperkingen, rate limits en DoS-basics\n\nDe meeste uploaduitval is geen slimme hack. Het zijn grote bestanden, te veel verzoeken, of trage verbindingen die servers bezet houden totdat de app onbruikbaar wordt. Behandel elke byte als een kost.\n\n### Stel grootte-limieten waar ze echt werken\n\nKies een maximale grootte per feature, niet één globaal getal. Een avatar heeft niet dezelfde limiet nodig als een belastingformulier of een korte video. Stel de kleinste limiet in die nog normaal aanvoelt, en voeg alleen een aparte “grote upload”-route toe als je het echt nodig hebt.\n\nHandhaaf limieten op meer dan één plek, omdat clients kunnen liegen: in de applicatielogica, bij de webserver of reverse proxy, met upload-timeouts en met vroege afwijzing wanneer de opgegeven grootte te groot is (voordat je de volledige body leest).\n\nConcreet voorbeeld: avatars begrensd op 2 MB, PDF's op 20 MB, en alles groter vereist een ander proces (zoals direct-naar-object-opslag met een ondertekende URL).\n\n### Rate limits en misbruikcontroles\n\nZelfs kleine bestanden kunnen DoS worden als iemand ze in een lus uploadt. Voeg rate limits toe op uploadendpoints per gebruiker en per IP. Overweeg strengere limieten voor anonieme verkeer dan voor ingelogde gebruikers.\n\nResumable uploads helpen echte gebruikers op slechte netwerken, maar de sessietoken moet strikt zijn: korte verloopperiode, gebonden aan de gebruiker en gekoppeld aan een specifieke bestandsgrootte en bestemming. Anders worden “resume”-endpoints een gratis pijplijn naar je opslag.\n\nAls je een upload blokkeert, geef duidelijke, gebruikersgerichte fouten terug (bestand te groot, te veel verzoeken) maar lek geen interne details (stack traces, bucket-namen, vendor-details).\n\n## Veilige opslag- en afleverkeuzes\n\nVeilige uploads gaan niet alleen over wat je accepteert. Het gaat ook over waar het bestand heen gaat en hoe je het later teruggeeft.\n\nHoud upload-bytes buiten je primaire database. De meeste apps hebben alleen metadata in de DB nodig (eigenaar user ID, originele bestandsnaam, gedetecteerd type, grootte, checksum, opslagkey, aanmaaktijd). Bewaar de bytes in objectopslag of een fileservice die is gebouwd voor grote blobs.\n\nScheid publieke en private bestanden op opslagniveau. Gebruik verschillende buckets of containers met verschillende regels. Publieke bestanden (zoals openbare avatars) kunnen zonder login leesbaar zijn. Privébestanden (contracten, facturen, medische documenten) mogen nooit publiek leesbaar zijn, ook niet als iemand de URL raadt.\n\nVermijd het serveren van gebruikersbestanden vanaf hetzelfde domein als je app waar mogelijk. Als er een risicovol bestand doorheen glipt (HTML, SVG met scripts, of browser MIME-sniffing rare situaties), kan hosting op je hoofddomein leiden tot accountovername. Een dedicated downloaddomein (of opslagdomein) beperkt de schade.\n\nBij downloads forceer je veilige headers. Stel een voorspelbare Content-Type in gebaseerd op wat je toestaat, niet wat de gebruiker beweert. Voor alles wat door een browser geïnterpreteerd kan worden, geef bij voorkeur een download-response.\n\nEen paar defaults die verrassingen voorkomen:\n\n- Gebruik Content-Disposition: attachment voor documenten.\n- Gebruik een veilige Content-Type (of application/octet-stream).\n- Sla op en serveer met ondoorzichtige objectkeys (niet gebruikersbestandsnamen).\n- Log downloads van privébestanden.\n\nRetentie is ook security. Verwijder verlaten uploads, verwijder oude versies na vervanging en stel tijdslimieten in voor tijdelijke bestanden. Minder opgeslagen data betekent minder om te lekken.\n\n## Ondertekende URL's: wanneer gebruiken en hoe ze scherp te houden\n\nOndertekende URL's (vaak pre-signed URLs genoemd) zijn een veelgebruikte manier om gebruikers te laten uploaden of downloaden zonder je opslagbucket openbaar te maken en zonder dat elke byte via je API gaat. De URL draagt tijdelijke permissie en verloopt daarna.\n\nTwee veelvoorkomende flows:\n\n- Direct-naar-opslag upload: je app geeft een kortdurende ondertekende URL en de browser uploadt rechtstreeks naar objectopslag.\n- Upload-door-server: het bestand komt eerst bij je API terecht, daarna slaat je server het op.\n\nDirect-naar-opslag vermindert API-load, maar maakt opslagregels en URL-beperking belangrijker.\n\n### Hoe houd je ondertekende URL's strak\n\nBehandel een ondertekende URL als een eenmalige sleutel. Maak het specifiek en kortdurend.\n\n- Laat schrijf-URL's snel verlopen (vaak 1–5 minuten). Houd lees-URL's in minuten, niet dagen.\n- Bind de URL aan de exacte objectkey die je verwacht (één object, geen map).\n- Voeg beperkingen toe waar ondersteund: verwacht content type, maximale grootte, checksum.\n- Geef URL's alleen uit na permissiecontroles.\n- Log wie de URL aanvroeg en waarom (user ID, objectkey, doel, IP/user agent).\n\nEen praktisch patroon is eerst een uploadrecord maken (status: pending), daarna de ondertekende URL uitgeven. Na upload bevestig je dat het object bestaat en overeenkomt met verwachte grootte en type voordat je het als klaar markeert.\n\n## Stap-voor-stap: een veilige uploadflow die je kunt implementeren\n\nEen veilige uploadflow is vooral duidelijke regels en duidelijke status. Behandel elke upload als onbetrouwbaar totdat controles klaar zijn.\n\nSchrijf op wat elke functie toestaat. Een profielfoto en een belastingdocument zouden niet dezelfde bestandstypen, grootte-limieten of zichtbaarheid moeten delen.\n\n### Een praktische flow (met echte statussen)\n\n1) Definieer toegestane types en een per-feature grootte-limiet (bijvoorbeeld: foto’s tot 5 MB; PDF's tot 20 MB). Handhaaf dezelfde regels in de backend.\n\n2) Maak een “uploadrecord” voordat de bytes arriveren. Bewaar: eigenaar (gebruiker of org), doel (avatar, factuur, bijlage), originele bestandsnaam, verwachte max-grootte en een status zoals pending.\n\n3) Upload naar een privé-locatie. Laat de client niet de uiteindelijke pad kiezen.\n\n4) Valideer opnieuw server-side: grootte, magic bytes/type, allowlist. Als het slaagt, zet de status op uploaded.\n\n5) Scan op malware en zet de status op clean of quarantined. Als scannen asynchroon is, houd toegang afgesloten terwijl je wacht.\n\n6) Sta downloaden, preview of verwerking alleen toe als de status clean is.\n\nKlein voorbeeld: voor een profielfoto maak je een record gekoppeld aan de gebruiker en doel avatar, sla je privé op, bevestig je dat het echt JPEG/PNG is (niet alleen zo genoemd), scan je het en genereer je daarna een preview-URL.\n\n## Basispatronen voor malware-scanning (zonder te veel te beloven)\n\nMalware-scanning is een vangnet, geen belofte. Het vangt bekende slechte bestanden en duidelijke trucs, maar detecteert niet alles. Het doel is simpel: risico verminderen en onbekende bestanden standaard onschadelijk maken.\n\nEen betrouwbaar patroon is eerst quarantaine. Sla elke nieuwe upload op in een private, niet-publieke locatie en markeer het als pending. Pas nadat het controles doorstaat verplaats je het naar een “clean” locatie (of markeer je het als beschikbaar).\n\nSynchronous scans werken alleen voor kleine bestanden en laag verkeer omdat de gebruiker wacht. De meeste apps scannen asynchroon: accepteer de upload, geef een “processing”-status terug, scan op de achtergrond.\n\n### Wat “basis scannen” meestal inhoudt\n\nBasis scannen is doorgaans een antivirus-engine (of dienst) plus een paar vangrails: AV-scan, bestands-type controles (magic bytes), archieflimieten (zip bombs, geneste zips, enorme uitgepakte grootte) en het blokkeren van formaten die je niet nodig hebt.\n\nAls de scanner faalt, timeouts geeft of “onbekend” teruggeeft, behandel het bestand als verdacht. Houd het in quarantaine en geef geen downloadlink. Hier lopen teams vast: “scan mislukt” mag niet betekenen “desondanks doorzetten.”\n\nAls je een bestand blokkeert, houd het bericht neutraal: “We konden dit bestand niet accepteren. Probeer een ander bestand of neem contact op met support.” Claim niet dat je malware hebt gedetecteerd tenzij je er zeker van bent.\n\n## Voorbeeld: profielfoto en documentupload in een gewone app\n\nBeschouw twee functies: een profielfoto (publiek getoond) en een PDF-bon (privé, gebruikt voor facturatie of support). Beide zijn uploadproblemen, maar ze mogen niet dezelfde regels delen.\n\nVoor de profielfoto houd je het strikt: alleen JPEG/PNG, groottebegrenzing (bijv. 2–5 MB), en re-encode aan de serverkant zodat je niet de originele bytes van de gebruiker serveert. Sla pas publiek op nadat controles zijn doorstaan.\n\nVoor de PDF-bon accepteer je een grotere grootte (bijv. tot 20 MB), houd je het standaard privé en vermijd je het inline renderen vanaf je hoofdappdomein.\n\nEen eenvoudig statusmodel houdt gebruikers geïnformeerd zonder interne details bloot te geven:\n\n- pending: gebruiker koos een bestand, upload nog niet gestart\n- uploaded: opslag ontving de bytes\n- scanning: achtergrondjob controleert het\n- clean (of rejected): bestand is beschikbaar (of geblokkeerd)\n\nOndertekende URL's passen hier goed: gebruik een kortdurende ondertekende URL voor upload (write-only, één objectkey). Geef een aparte kortdurende ondertekende URL voor lezen, en alleen wanneer de status clean is.\n\nLog wat je nodig hebt voor onderzoek, niet het bestand zelf: user ID, file ID, type-guess, grootte, opslagkey, timestamps, scanresultaat, request IDs. Vermijd het loggen van ruwe inhoud of gevoelige data die in documenten staat.Begin met privé als standaard en behandel elke upload als onbetrouwbare invoer. Handhaaf vier basiszaken op de server:\n\n- Wie mag uploaden\n- Welke bestandsformaten je accepteert (allowlist)\n- Hoe groot/hoe vaak (grootte- + rate limits)\n- Wie het later kan lezen (per-bestand permissiecontroles)\n\nAls je deze vragen duidelijk kunt beantwoorden, zit je al voor op de meeste incidenten.
Omdat gebruikers een “mystery box” kunnen uploaden die je app opslaat en later aan anderen kan tonen. Dat kan leiden tot:\n\n- Ongeautoriseerde toegang tot privédocumenten\n- Phishing of accountovername als een bestand als vertrouwde webcontent wordt geserveerd\n- Uitval en hoge kosten door uploadflut of enorme bestanden\n\nHet is zelden alleen “iemand uploadde een virus.”
Opslag is het bewaren van bytes. Serveren is het afleveren van die bytes aan browsers en apps.\n\nHet gevaar ontstaat wanneer je app gebruikersuploads serveert met dezelfde mate van vertrouwen en regels als je hoofdsite. Als een risicovol bestand als normale pagina wordt behandeld, kan de browser het uitvoeren (of gebruikers zullen het te veel vertrouwen).\n\nEen veiliger standaard is: sla privé op en serveer vervolgens via gecontroleerde downloadreacties met veilige headers.
Gebruik default deny en verifieer toegang elke keer dat een bestand gedownload of bekeken wordt.\n\nPraktische regels:\n\n- Elk bestandsrecord moet een eigenaar hebben (gebruiker/organisatie) en een doel (avatar, factuur, etc.)\n- Bij lezen/download controleer je of de aanvrager voor dat specifieke bestand toestemming heeft\n- Vermijd “map-gebaseerde” beveiliging zoals “alles onder /uploads/ is goed”\n- Houd supporttoegang tijdelijk en gelogd (geef toegang tot één bestand, laat het automatisch verlopen)\n\nDe meeste echte bugs zijn simpele “ik kan het bestand van een andere gebruiker zien”-fouten.
Vertrouw niet op een bestandsnaamextensie of de Content-Type die de browser stuurt. Valideer op de server:\n\n- Gebruik een allowlist van formaten per functie (bijv. JPEG/PNG voor avatars, PDF voor bonnetjes)\n- Detecteer het type aan de serverzijde en controleer magic bytes (bestands-signaturen)\n- Hernoem bestanden voor opslag met een willekeurige ID; bewaar de originele naam alleen als metadata\n- Blokkeer risicovolle formaten die je niet nodig hebt (vooral HTML, SVG en script-achtige content)\n\nAls de bytes niet overeenkomen met een toegestaan formaat, weiger de upload.
Omdat storingen vaak komen van saaie misbruikvormen: te veel uploads, gigantische bestanden of trage verbindingen die serverresources vasthouden.\n\nWerkbare defaults:\n\n- Stel per-feature maximale groottes in (avatars klein, documenten groter)\n- Handhaaf limieten op meerdere lagen (app + reverse proxy + timeouts)\n- Voeg rate limits toe per gebruiker en per IP, met strengere limieten voor anonieme verzoeken\n\nBehandel elke byte als kost en elk verzoek als potentiële misbruik.
Ja, maar doe het voorzichtig. Ondertekende URL's laten de browser direct naar objectopslag uploaden of downloaden zonder de bucket openbaar te maken.\n\nGoede defaults:\n\n- Hou schrijvende URL's kort (vaak 1–5 minuten)\n- Scope elke URL naar één objectkey, niet een map\n- Geef URL's alleen na permissiecontroles uit\n- Log wie de URL aanvraagt en voor welk bestand\n\nDirect-naar-opslag vermindert API-load, maar maakt scoping en expiratie niet-onderhandelbaar.
Het veiligste patroon is:\n\n1. Maak een uploadrecord met status pending\n2. Upload bytes naar een privé locatie\n3. Valideer grootte + type (magic bytes) op de server\n4. Scan (meestal async) en zet status op clean of quarantined\n5. Sta download/preview alleen toe als de status clean is\n\nDit voorkomt dat “scan mislukt” of “nog in verwerking” bestanden per ongeluk gedeeld worden.
Scannen helpt, maar het is geen garantie. Gebruik het als vangnet, niet als enige controle.\n\nPraktische aanpak:\n\n- Quarantaine eerst: toon geen links totdat scannen klaar is\n- Scan asynchroon voor schaal; laat de gebruiker “in verwerking” zien\n- Als scannen faalt of timeouts geeft, behandel het bestand als verdacht en blokkeer het\n- Voeg guardrails toe voor archieven (zip bombs, enorme uitgepakte groottes) als je die toestaat\n\nDe kern is beleid: “niet gescand” mag nooit “beschikbaar” betekenen.
Serveer bestanden zo dat browsers ze níet als webpagina interpreteren.\n\nGoede defaults:\n\n- Zet Content-Disposition: attachment voor documenten\n- Gebruik een veilige, server-gevraagde Content-Type (of application/octet-stream)\n- Gebruik ondoorzichtige opslagsleutels (geen gebruikersnamen in URL's)\n- Geef bij voorkeur gebruikerscontent via een aparte download-domein als dat mogelijk is\n\nDit verkleint het risico dat een geüpload bestand verandert in een phishingpagina of scriptuitvoering.