Beveiligde file uploads in Laravel — PCI-DSS overwegingen
Terug naar blog

Beveiligde file uploads in Laravel — PCI-DSS overwegingen

AuthorRuthger Idema
30 juni 20268 min leestijd

Beveiligde file uploads in Laravel: van mime-validatie en opslag buiten de webroot tot virus scanning, signed URLs en wat PCI-DSS v4.0 er concreet over zegt.

Beveiligde file uploads in Laravel — PCI-DSS overwegingen

Ruim 43% van alle datalekken start bij een onveilige bestandsupload. Niet bij een SQL-injection, niet bij een zwak wachtwoord — bij een uploadformulier dat te goed van vertrouwen is. In Laravel bouw je zo'n formulier in een middag. Maar goed beveiligen? Dat vraagt om meer dan $request->validate(['file' => 'required|file']).

Dit artikel gaat over wat er mis kan gaan, hoe je het aanpakt, en wat PCI-DSS daar concreet over zegt. Praktisch, met code. Geen theorie voor de theorie.

In de context van e-commerce — denk aan klantportalen, factuuruploads of importfuncties voor orderfeeds — is file upload-beveiliging geen nice-to-have. Het raakt direct je PCI-DSS scope zodra uploads ook maar in de buurt van betalingsdata komen.


De gevaren van een naieve file upload

Een onbeveiligde upload accepteert alles. Een aanvaller uploadt een PHP-script als invoice.php, raadt de opslaglocatie en voert het uit. Resultaat: remote code execution op jouw server.

Veelvoorkomende aanvalsvectoren:

  • Content-type spoofing — de browser stuurt image/jpeg, maar de inhoud is PHP.
  • Double extensionshell.php.jpg slipt door een te simpele mime-check.
  • Path traversal — een bestandsnaam als ../../config/database.php overschrijft config.
  • Malware in uploads — PDF's of Word-bestanden met embedded exploits.

In de praktijk zien wij bij Laravel-klanten dat de basis vaak klopt, maar de edge-cases ontbreken. Geen virus scanning, bestanden wél binnen de webroot, signed URLs die nooit verlopen. Een kwartaal later is er een incident, dan is het dweilen met de kraan open.


Validatie: mime, size, extensie

Validatie is de eerste verdedigingslinie. Laravel's ingebouwde validatie is goed, maar niet voldoende als je alleen op de extensie vertrouwt. Een aanvaller die een bestand hernoemt van shell.php naar invoice.pdf passeert een extensie-check zonder moeite — de magic bytes in het bestand verraden de werkelijke inhoud.

php
$request->validate([
    'document' => [
        'required',
        'file',
        'max:10240', // 10 MB in kilobytes
        'mimes:pdf,docx,xlsx',
        'mimetypes:application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    ],
]);
mimes controleert de extensie. mimetypes controleert de werkelijke MIME-type via finfo_file() — dat leest de magic bytes van het bestand zelf. Gebruik altijd allebei.

Aanvullende checks

Laravel's validatie volstaat niet voor high-risk omgevingen. Voeg toe:

php
use Illuminate\Http\UploadedFile;

function isValidFile(UploadedFile $file): bool
{
    // Controleer op null bytes in bestandsnaam
    if (str_contains($file->getClientOriginalName(), "\0")) {
        return false;
    }

    // Blokkeer dubbele extensies
    $name = $file->getClientOriginalName();
    $parts = explode('.', $name);
    if (count($parts) > 2) {
        // Meer dan één punt — potentieel gevaarlijk
        $dangerousExtensions = ['php', 'phtml', 'php3', 'php4', 'phar', 'sh', 'exe', 'bat'];
        foreach (array_slice($parts, 1, -1) as $part) {
            if (in_array(strtolower($part), $dangerousExtensions)) {
                return false;
            }
        }
    }

    return true;
}

Sla de bestandsnaam die de gebruiker opgeeft nooit op als-is. Genereer een UUID of hash als bestandsnaam. De originele naam bewaar je eventueel in de database, maar nooit als de werkelijke bestandsnaam op schijf of in object storage.

php
$filename = Str::uuid() . '.' . $file->getClientOriginalExtension();

Opslag: buiten de webroot of op S3

Bestanden die uploadbaar zijn mogen nooit direct bereikbaar zijn via een URL. Sla op in storage/ (buiten public/), of beter: op een externe object storage zoals S3 of DigitalOcean Spaces.

Lokale opslag buiten webroot

php
$path = $file->store('uploads/documents', 'local');
// Slaat op in storage/app/uploads/documents/
// Niet bereikbaar via browser

Toegang via een controller die authorisatie controleert:

php
public function download(string $uuid): StreamedResponse
{
    $document = Document::where('uuid', $uuid)->firstOrFail();

    // Controleer of de ingelogde gebruiker recht heeft
    $this->authorize('view', $document);

    return Storage::disk('local')->download($document->path, $document->original_name);
}

S3 als productie-standaard

Voor e-commerce, zeker bij Shopify-migraties of Magento-integraties, is S3 de standaard. Bestanden staan nooit op de applicatieserver.

php
// config/filesystems.php
's3' => [
    'driver' => 's3',
    'key'    => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION'),
    'bucket' => env('AWS_BUCKET'),
    'url'    => env('AWS_URL'),
    'visibility' => 'private', // ALTIJD private voor gevoelige uploads
],
php
$path = $file->store('uploads', 's3');

Stel de bucket-policy zo in dat publieke toegang standaard geblokkeerd is. Gebruik bucket-level server-side encryption (SSE-S3 of SSE-KMS).


Signed URLs: tijdgebonden toegang

Private S3-bestanden zijn niet direct bereikbaar. Je geeft toegang via een signed URL — een tijdelijke URL die na X minuten verloopt.

php
use Illuminate\Support\Facades\Storage;

$url = Storage::disk('s3')->temporaryUrl(
    $document->path,
    now()->addMinutes(15) // Vervalt na 15 minuten
);

Een signed URL werkt via een HMAC-handtekening over het pad en de vervaltijd. Iemand die de URL heeft maar niet de geheime sleutel, kan de URL niet verlengen of aanpassen. Kies de vervaltijd bewust:

GebruikAanbevolen TTL
Download in browser na klik5–15 minuten
Gedeelde link voor klant1–24 uur
Interne verwerking (queue)30 minuten
Nooit verlopen linkNooit doen

Een signed URL die niet verloopt is geen signed URL — het is een publieke URL met een lang pad. Log elke URL-generatie voor auditdoeleinden.


Virus scanning met ClamAV of een cloud-service

Bestanden scannen op malware is niet optioneel zodra je uploads accepteert van externe gebruikers. Zeker niet als je die bestanden doorstuurt naar klanten of interne systemen.

Optie 1: ClamAV (self-hosted)

ClamAV is gratis en werkt goed voor low-to-medium volume. Via de socket of TCP:

php
use Xenolope\Quahog\Client as ClamAV;

$clam = new ClamAV(new \Socket\Raw\Factory(), 3, '/var/run/clamav/clamd.ctl');
$clam->startSession();
$result = $clam->scanFile(storage_path('app/' . $path));

if ($result->isFound()) {
    Storage::delete($path);
    throw new \Exception('Malware gedetecteerd: ' . $result->getReason());
}

Reken op 50–200 ms per bestand afhankelijk van de grootte. Doe dit asynchroon via een queue-job, niet in de request-lifecycle.

Optie 2: Cloud-gebaseerd (AWS GuardDuty Malware Protection, VirusTotal API)

AWS GuardDuty Malware Protection for S3 scant automatisch nieuwe uploads in een S3-bucket. Geen code nodig in de applicatie — het werkt via S3 Event Notifications. Doorgaans enkele dollars per maand voor moderate volumes.

VirusTotal heeft een gratis API-tier (max 4 requests/minuut). Voor productie gebruik de betaalde variant.


Wat PCI-DSS wel en niet eist rond uploads

PCI-DSS v4.0 raakt file uploads op meerdere plekken. Niet altijd expliciet, maar de principes zijn duidelijk.

Wat PCI-DSS direct eist

Requirement 6.2.4 — Software development practices moeten aanvallen voorkomen, inclusief "injection attacks" en "malicious file uploads". Concreet: valideer bestandstype en -inhoud, sla niet op in webroot, controleer op malware. Requirement 10.2 — Log alle toegang tot cardholder data. Als uploads cardholder data kunnen bevatten (denk: inkooporders, facturen met kaartnummers), log dan elke upload, download en verwijdering met timestamp, gebruiker en IP. Requirement 12.3.2 — Voer een targeted risk analysis uit voor elke custom technische control. File upload-functionaliteit valt hieronder als het de CDE (Cardholder Data Environment) raakt. Documenteer je keuzes: welke bestandstypen accepteer je, waarom, hoe scan je, hoe lang bewaar je bestanden en wie heeft toegang.

Wat PCI-DSS niet expliciet eist

PCI-DSS schrijft geen specifieke technologie voor. ClamAV versus een cloud-service: beide zijn acceptabel, als je maar kunt aantonen dat je scant. Signed URLs zijn niet verplicht — maar als je uploads in de CDE opslaat zonder toegangscontrole, faal je op requirement 7 (least privilege).

Loggen: wat moet erin

php
// Minimale log-entry bij upload in PCI-scope
Log::channel('audit')->info('file_upload', [
    'user_id'     => auth()->id(),
    'ip'          => $request->ip(),
    'filename'    => $filename, // UUID, niet originele naam
    'mime_type'   => $file->getMimeType(),
    'size_bytes'  => $file->getSize(),
    'storage_path'=> $path,
    'scan_result' => $scanResult,
    'timestamp'   => now()->toISOString(),
]);

Stuur deze logs naar een aparte log-aggregator (Papertrail, CloudWatch, Elastic). Niet alleen naar storage/logs/ op de applicatieserver — die logs zijn verwijderbaar door een aanvaller met server-toegang.

PCI-DSS requirement 10.5.1 eist dat audit logs beschermd zijn tegen wijziging. Een write-only log-stream naar een externe service voldoet hieraan. Laravel's standaard file-logging niet.


Alles samengevat: implementatievolgorde

Begin niet bij virus scanning. Begin bij de basis en werk omhoog:

  1. Validatie — mime + mimetypes + grootte + bestandsnaam sanitizering
  2. Opslag — buiten webroot of op private S3
  3. Toegangscontrole — authorisatie-check bij elke download
  4. Signed URLs — tijdgebonden, altijd gelogd
  5. Virus scanning — asynchroon via queue
  6. Audit logging — centraal, onveranderlijk

Stap 1 t/m 4 zijn in een dag te implementeren in een bestaand Laravel-project. Stap 5 en 6 kosten meer tijd, afhankelijk van je infrastructuur. Wij adviseren stap 5 (virus scanning) via een queue-job te doen, zodat de upload-response snel blijft en de scan asynchroon verloopt. Zet de bestandsstatus op pending totdat de scan groen geeft, en blokkeer downloads in de tussentijd.


Wanneer is dit NIET genoeg

Beveilig je uploads zoals hierboven beschreven, maar realiseer je: als je cardholder data verwerkt in de bestanden zelf (denk aan gescande facturen met kaartnummers), dan valt de volledige opslag in scope voor PCI-DSS. Dat betekent data-at-rest-encryptie, key management, stricter netwerksegmentatie.

Wil je dat voorkomen? Zorg dan dat uploads nooit cardholder data bevatten. Gebruik tokenisatie en laat de payment provider de gevoelige data afhandelen. Dat is architectuur, geen Laravel-configuratie.

Bij Shopify Plus en Magento-projecten lossen wij dit standaard op door de betaalpagina volledig buiten onze servers te houden. De upload-functionaliteit (productafbeeldingen, documenten, klantbestanden) blijft dan buiten PCI-scope.


Veelgestelde vragen

Moet ik bestanden altijd buiten de webroot opslaan?

Ja, als het om gebruikersbestanden gaat. Publieke productafbeeldingen mogen in public/ — die zijn toch openbaar. Facturen, contracten, klantdocumenten horen nooit in public/. Sla ze op in storage/app/ of op S3 met private visibility.

Is ClamAV goed genoeg voor PCI-DSS compliance?

ClamAV is acceptabel, mits je de virusdefinities automatisch bijhoudt (doorgaans elke paar uur) en de scanresultaten logt. PCI-DSS eist aantoonbare malware-preventie, niet een specifiek product. Documenteer je keuze en de update-frequentie in je security policies.

Hoe lang mogen signed URLs geldig zijn?

Zo kort als mogelijk is voor de use case. Voor een directe download: 5 tot 15 minuten. Voor een link die je deelt met een klant: maximaal 24 uur, liever minder. Verlopen URLs forceer je te vernieuwen via je applicatie — zo hou je controle en audittrail.

Wat als mijn klant zegt dat ze PCI-compliant zijn — moet ik dan nog iets doen?

PCI-compliance van de klant zegt niets over jouw systeem. Als jij de applicatie bouwt en beheert die uploads verwerkt, ben jij verantwoordelijk voor de technische implementatie. De klant is verantwoordelijk voor zijn SAQ of QSA-audit. Die twee overlappen, maar zijn niet hetzelfde. Twijfel je over scope? Neem contact op — wij helpen de CDE-grens correct te trekken.

Ruthger Idema

Geschreven door Ruthger Idema

15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.

Meer over ons team →
Deel dit artikel:

Wil je jouw e-commerce naar het volgende niveau?

Plan een vrijblijvend gesprek met onze experts over Magento, Shopify of Laravel maatwerk.

Plan een Tech Check