Bestand uploaden in PHP: validatie, beveiliging en base64

Bestand uploaden in PHP: validatie, beveiliging en base64
Kort antwoord

Het MIME-type en de bestandsnaam die de browser aankondigt zijn niets waard: lees het werkelijke type met finfo, controleer de afmetingen, encodeer de afbeelding opnieuw en genereer de naam met random_bytes. Sla op buiten de webroot, in een map die niets uitvoert. Voor een afbeelding in base64 decodeer je in strikte modus en controleer je de grootte voordat je decodeert.

Een bestand aannemen dat een bezoeker opstuurt is een van de risicovolste functies van een webapplicatie. Eén controle op de verkeerde plek en de server voert het ontvangen bestand uit. Deze tutorial laat het formulier zien, daarna de verwerking, en vervolgens de controles die echt standhouden, met de tests die aantonen welke dat niet doen. Ook het versturen van afbeeldingen in base64 komt aan bod, gebruikelijk bij JavaScript-croptools.

Het formulier

public/formulaire.html
<form action="upload.php" method="post" enctype="multipart/form-data">
    <input type="hidden" name="MAX_FILE_SIZE" value="5242880">
    <label for="photo">Photo</label>
    <input type="file" name="photo" id="photo" accept="image/jpeg,image/png,image/gif,image/webp" required>
    <button type="submit">Envoyer</button>
    <p>Formats acceptés : JPEG, PNG, GIF, WebP. 5 Mo maximum.</p>
</form>

Het attribuut enctype="multipart/form-data" is verplicht: zonder dat attribuut stuurt de browser alleen de bestandsnaam en blijft $_FILES leeg. Het attribuut accept en het veld MAX_FILE_SIZE verbeteren de ervaring in de browser, beveiligingscontroles zijn het niet, want een request kan ook buiten het formulier om in elkaar worden gezet.

Wat er in $_FILES zit

Een bestand dat via het veld photo binnenkomt, staat in $_FILES['photo'], een associatieve array met vijf entries. Twee daarvan komen van de client en mogen nooit als controle dienen.

Sleutel Inhoud Bron
name Oorspronkelijke bestandsnaam, inclusief extensie de client
type Aangekondigd MIME-type de client
size Grootte in bytes van het ontvangen bestand de server
tmp_name Pad van het tijdelijke bestand op de server de server
error Statuscode van de upload, 0 als alles goed ging de server

Het ontvangen bestand wordt in een tijdelijke map geschreven en aan het einde van het script verwijderd. Wil je het bewaren, dan verplaats je het met move_uploaded_file(), of je hebt het vóór het einde van het request al ingelezen. De functie is_uploaded_file() controleert of het pad echt naar een bestand uit een HTTP-upload wijst, zodat de verwerking niet op een systeembestand kan worden gericht.

De controle die nergens tegen beschermt

Het volgende patroon staat in de meeste tutorials, ook in eerdere versies van deze.

php
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];

$type = $_FILES['photo']['type'];      // <- aangekondigd door de browser
if (in_array($type, $autorises)) {
    move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}

Twee lekken, gemeten op PHP 8.5.10.

De waarde $_FILES['photo']['type'] komt van de client. PHP controleert die nooit. Een zelf samengestelde upload kan van alles aankondigen:

code
type annoncé   : image/png
type réel      : text/x-php
getimagesize() : false

Het bestand is een PHP-script en de controle laat het door.

Ook de oorspronkelijke bestandsnaam komt van de client. Die ongewijzigd hergebruiken om een pad te bouwen zet de deur open voor meerdere vormen van misbruik:

code
'../../config.php'  -> extension : php   basename : config.php
'photo.php.png'     -> extension : png   basename : photo.php.png
'photo.png.php'     -> extension : php   basename : photo.png.php
'photo.pHp'         -> extension : pHp   basename : photo.pHp

Het geval photo.php.png is het gemeenste: de extensie die pathinfo() ziet is inderdaad png, dus de controle gaat door, maar een Apache met AddHandler op .php kan het bestand uitvoeren vanwege de tussenliggende extensie. Het geval photo.pHp herinnert eraan dat een extensievergelijking hoofdlettergevoelig is.

De validatie die wel standhoudt

Drie principes: het type lees je uit de inhoud, niet uit wat de client aankondigt, de naam wordt opnieuw gegenereerd, nooit hergebruikt, het afbeeldingsbestand wordt opnieuw geëncodeerd.

src/upload.php
<?php

declare(strict_types=1);

const TYPES_AUTORISES = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/gif'  => 'gif',
    'image/webp' => 'webp',
];

/**
 * @return array{0: bool, 1: string} succes en bestandsnaam, of mislukking en reden
 */
function validerImage(
    string $chemin,
    int $maxOctets = 5_242_880,
    int $maxPixels = 50_000_000,
): array {
    if (!is_file($chemin)) {
        return [false, 'fichier absent'];
    }

    $taille = filesize($chemin);
    if ($taille === false || $taille === 0) {
        return [false, 'fichier vide'];
    }
    if ($taille > $maxOctets) {
        return [false, 'trop volumineux'];
    }

    // Het werkelijke type, gelezen uit de bytes van het bestand
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($chemin);
    if (!isset(TYPES_AUTORISES[$mime])) {
        return [false, "type refusé : {$mime}"];
    }

    $infos = @getimagesize($chemin);
    if ($infos === false) {
        return [false, 'image illisible'];
    }
    [$largeur, $hauteur] = $infos;
    if ($largeur < 1 || $hauteur < 1) {
        return [false, "dimensions invalides ({$largeur}x{$hauteur})"];
    }
    if ($largeur * $hauteur > $maxPixels) {
        return [false, 'bombe de décompression'];
    }

    // Herencodering: de enige betrouwbare verdediging tegen polyglotbestanden
    $image = @imagecreatefromstring(file_get_contents($chemin));
    if ($image === false) {
        return [false, 'décodage impossible'];
    }

    // Onvoorspelbare naam, en rechtstreeks schrijven naar de opslagmap.
    // Via tempnam() en daarna rename() zou mislukken als /tmp en de opslag
    // op twee verschillende filesystems staan, wat gebruikelijk is
    // in een container.
    $nom = bin2hex(random_bytes(16)) . '.' . TYPES_AUTORISES[$mime];
    $destination = __DIR__ . '/../stockage/' . $nom;

    $ok = match (TYPES_AUTORISES[$mime]) {
        'jpg'  => imagejpeg($image, $destination, 85),
        'png'  => imagepng($image, $destination),
        'gif'  => imagegif($image, $destination),
        'webp' => imagewebp($image, $destination, 85),
    };
    if (!$ok) {
        return [false, 'réencodage impossible'];
    }

    return [true, $nom];
}

De verwerking van het request, met de foutcodes:

upload.php
<?php

declare(strict_types=1);

require __DIR__ . '/src/upload.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Méthode non autorisée');
}

if (!isset($_FILES['photo'])) {
    http_response_code(400);
    exit('Aucun fichier reçu');
}

$erreur = $_FILES['photo']['error'];

if ($erreur !== UPLOAD_ERR_OK) {
    $message = match ($erreur) {
        UPLOAD_ERR_INI_SIZE   => 'Fichier plus grand que upload_max_filesize',
        UPLOAD_ERR_FORM_SIZE  => 'Fichier plus grand que MAX_FILE_SIZE',
        UPLOAD_ERR_PARTIAL    => 'Envoi interrompu',
        UPLOAD_ERR_NO_FILE    => 'Aucun fichier sélectionné',
        UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire absent sur le serveur',
        UPLOAD_ERR_CANT_WRITE => 'Écriture impossible sur le disque',
        UPLOAD_ERR_EXTENSION  => 'Envoi bloqué par une extension PHP',
        default               => 'Erreur inconnue',
    };
    http_response_code(400);
    exit($message);
}

// Garandeert dat het bestand echt uit een HTTP-upload komt
if (!is_uploaded_file($_FILES['photo']['tmp_name'])) {
    http_response_code(400);
    exit('Envoi invalide');
}

[$succes, $resultat] = validerImage($_FILES['photo']['tmp_name']);

if (!$succes) {
    http_response_code(422);
    exit('Fichier refusé : ' . $resultat);
}

echo 'Enregistré sous ', htmlspecialchars($resultat, ENT_QUOTES, 'UTF-8');

Wat elke controle tegenhoudt

Vier bestanden door validerImage() gehaald, op PHP 8.5.10 met GD 2.0.35:

Verstuurd bestand Resultaat Controle die aansloeg
PHP-script hernoemd naar .png geweigerd werkelijk type: text/x-php
Polyglot: GIF-header + PHP-code geweigerd afmetingen (0×0 of absurd)
Echte PNG 120×120 geaccepteerd
Echte PNG met PHP achteraan geplakt geaccepteerd opnieuw geëncodeerd, de payload verdwijnt

De tweede regel verdient aandacht. Een bestand dat begint met GIF89a gevolgd door PHP-code wordt door finfo als image/gif herkend. De detectiebibliotheek vertrouwt op de handtekening van de eerste bytes, en die handtekening klopt. Alleen de afmetingscontroles wijzen het af: afhankelijk van de bytes na de header leest getimagesize() 0×0 of absurde afmetingen, en dan weigert het pixelplafond het bestand. Een validatie die bij finfo stopt, laat het door.

De vierde regel is het meest voorkomende geval: een volkomen geldige afbeelding waar een payload achteraan is geplakt. Die wordt geaccepteerd, en terecht, want het is een echte afbeelding, maar het opgeslagen bestand is het resultaat van de herencodering, niet het origineel. De payload overleeft dat niet.

Opslag telt net zo zwaar als validatie

Geen enkele validatie vervangt een opslagmap die niets uitvoert. Die map hoort buiten de webroot te staan en te worden uitgeserveerd door een script dat het juiste contenttype meestuurt.

stockage/.htaccess
<IfModule mod_php.c>
    php_flag engine off
</IfModule>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Header set X-Content-Type-Options "nosniff"
nginx.conf
location ^~ /stockage/ {
    location ~ \.php$ { return 403; }
    add_header X-Content-Type-Options "nosniff" always;
}

De header X-Content-Type-Options: nosniff voorkomt dat de browser het type van een bestand gaat raden en HTML of JavaScript uitvoert die in een afbeelding verstopt zit.

De limieten in php.ini beslissen vóór jouw code

php
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
    printf("%-20s %s\n", $d, ini_get($d));
}

Een bestand dat groter is dan post_max_size komt binnen met een volledig lege $_FILES én $_POST: de test isset($_FILES['photo']) mislukt en de foutmelding wijst de verkeerde kant op. Dat geval moet je apart opvangen:

php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES)
    && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
    http_response_code(413);
    exit('Envoi plus grand que post_max_size (' . ini_get('post_max_size') . ')');
}

Dit blok heeft een voorwaarde die we al testend ontdekten: het werkt alleen als display_errors uitstaat. PHP geeft zijn eigen waarschuwing af voordat er ook maar één regel van het script draait, en zodra die waarschuwing verschijnt, zijn de headers al verstuurd.

code
display_errors = On   -> HTTP 200 + « Warning: PHP Request Startup: POST Content-Length
                         of 8388808 bytes exceeds the limit of 6291456 bytes »
                         puis « Cannot set response code - headers already sent »
display_errors = Off  -> HTTP 413 « Envoi plus grand que post_max_size (6M) »

In productie hoort display_errors sowieso op Off te staan. Dit is een reden te meer.

Met upload_max_filesize = 5M en post_max_size = 6M komt een bestand van 5,5 MB langs de eerste barrière en wordt het door de tweede tegengehouden, met UPLOAD_ERR_INI_SIZE en een code 400. post_max_size moet groter blijven dan upload_max_filesize, anders heeft die tweede waarde geen enkel effect.

Een afbeelding in base64 ontvangen

JavaScript-croptools en opnamen uit een <canvas> sturen de afbeelding vaak als een string data:image/png;base64,… in plaats van als bestand. Het validatieprincipe verandert niet, maar er komen twee valkuilen bij.

De eerste: base64_decode() accepteert zonder tweede argument van alles en geeft willekeurige bytes terug in plaats van een fout.

php
var_dump(base64_decode('ceci n est pas du base64 !!'));       // string(9) "q..." — willekeurige bytes
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)

De tweede: een base64-string is ongeveer een derde groter dan de data die erin zit. De grootte vóór het decoderen controleren scheelt 40 MB alloceren om vervolgens een bestand van 30 te weigeren.

src/base64.php
<?php

declare(strict_types=1);

/**
 * @return array{0: bool, 1: string}
 */
function enregistrerImageBase64(
    string $entree,
    string $dossier,
    int $maxOctets = 2_097_152,
): array {
    if (!preg_match('#^data:image/(jpeg|png|gif|webp);base64,#', $entree, $m)) {
        return [false, 'préfixe data: absent ou type non autorisé'];
    }

    $encode = substr($entree, strlen($m[0]));

    // Base64 weegt 4/3 van de data: eerst filteren, dan decoderen
    if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
        return [false, 'trop volumineux (avant décodage)'];
    }

    $binaire = base64_decode($encode, true); // strict
    if ($binaire === false) {
        return [false, 'base64 invalide'];
    }
    if (strlen($binaire) > $maxOctets) {
        return [false, 'trop volumineux'];
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binaire);
    $extension = match ($mime) {
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/gif'  => 'gif',
        'image/webp' => 'webp',
        default      => null,
    };
    if ($extension === null) {
        return [false, "type réel refusé : {$mime}"];
    }

    $infos = @getimagesizefromstring($binaire);
    if ($infos === false || $infos[0] < 1 || $infos[1] < 1) {
        return [false, 'image illisible'];
    }

    $nom = bin2hex(random_bytes(16)) . '.' . $extension;
    if (file_put_contents(rtrim($dossier, '/') . '/' . $nom, $binaire, LOCK_EX) === false) {
        return [false, 'écriture impossible'];
    }

    return [true, $nom];
}

Gemeten resultaten op vijf invoeren:

Invoer Resultaat Reden
Geldige PNG 1×1 geaccepteerd
PHP-code aangekondigd als image/png geweigerd werkelijk type: text/x-php
SVG met onload geweigerd type niet toegestaan
Willekeurige string geweigerd prefix data: ontbreekt
Beschadigde base64 geweigerd strikte decodering mislukt

SVG sluit je bewust uit. Het is een XML-formaat dat JavaScript en externe referenties kan bevatten. Moet je het toch accepteren, laat het dan door een speciale sanitizer gaan en serveer het nooit vanaf hetzelfde domein als de applicatie.

Twee valkuilen in de code die overal rondslingert

Een naïeve split van een data:-string gaat ervan uit dat de invoer welgevormd is:

php
$parties = explode(';base64,', $img);
$aux     = explode('image/', $parties[0]);
$type    = $aux[1];              // Undefined array key 1 als de invoer willekeurig is
$binaire = base64_decode($parties[1]); // Undefined array key 1, daarna een deprecation op null

En een bestandsnaam die met uniqid() is gemaakt, is niet onvoorspelbaar: de functie is afgeleid van de klok en opeenvolgende waarden zijn te raden.

code
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96

bin2hex(random_bytes(16)) gebruikt de cryptografische generator van het systeem. Dat is de enige juiste vorm wanneer de naam niet te raden mag zijn.

Om te onthouden

  • Het type en de naam die de client aankondigt zijn niets waard: lees het type uit de inhoud en genereer de naam opnieuw.
  • finfo alleen volstaat niet, een GIF-polyglot misleidt het. Voeg de controle op de afmetingen en de herencodering toe.
  • Sla op buiten de webroot, in een map die niets uitvoert.
  • Bij base64: strikte decodering, groottecontrole vóór het decoderen, SVG weigeren.

Wil je de naam van het opgeslagen bestand naar de interface terugsturen zonder een gat te slaan, zie variabelen van PHP naar JavaScript doorgeven. Een ontvangen bestand van enkele tientallen megabytes verwerk je niet in één keer: zie grote bestanden lezen met PHP. Na het opslaan voorkomt een 303-redirect dat het formulier bij een herlaadactie opnieuw wordt verstuurd. En de validatieklasse uit dit artikel volgt de principes van OOP in PHP.

Verder lezen: afbeeldingen bewerken met GD en Imagick en de hub Webdevelopment.

Veelgemaakte fouten

Het MIME-type uit $_FILES komt van de client Een zelf samengestelde upload kondigt image/png aan voor een PHP-script. Het werkelijke type lees je met finfo, nooit uit $_FILES['x']['type'].
Oorspronkelijke bestandsnaam hergebruikt photo.php.png heeft voor pathinfo() inderdaad de extensie png, maar een verkeerd geconfigureerde Apache kan het bestand uitvoeren vanwege de tussenliggende extensie. De naam genereer je opnieuw.
finfo alleen volstaat niet Een bestand dat met GIF89a begint en daarna PHP-code bevat, wordt als image/gif herkend. Alleen de controle op de afmetingen wijst het af.
uniqid() om een bestand te benoemen De functie is afgeleid van de klok en haar waarden zijn te raden. Gebruik bin2hex(random_bytes(16)).
base64_decode() niet strikt Zonder het tweede argument op true accepteert de functie van alles en geeft ze willekeurige bytes terug in plaats van false.
post_max_size overschreden $_FILES en $_POST komen leeg binnen. En de detectiecode werkt alleen als display_errors op Off staat: anders toont PHP zijn waarschuwing vóór jouw script en zijn de headers al verstuurd.
tempnam() gevolgd door rename() Mislukt als de tijdelijke map en de opslag op twee verschillende filesystems staan, wat in een container gebruikelijk is. Schrijf direct naar de bestemming.
SVG geaccepteerd als afbeelding Het is XML dat JavaScript kan bevatten. Uitsluiten, of saneren en vanaf een ander domein serveren.

Base64ImagesPHPSécuritéUpload

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.