
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
<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.
$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:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseHet 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:
'../../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.pHpHet 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.
<?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:
<?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.
<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"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
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:
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.
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.
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.
<?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:
$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 nullEn een bestandsnaam die met uniqid() is gemaakt, is niet onvoorspelbaar: de functie is afgeleid van de klok en opeenvolgende waarden zijn te raden.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(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.
finfoalleen 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
image/png aan voor een PHP-script. Het werkelijke type lees je met finfo, nooit uit $_FILES['x']['type'].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.GIF89a begint en daarna PHP-code bevat, wordt als image/gif herkend. Alleen de controle op de afmetingen wijst het af.bin2hex(random_bytes(16)).true accepteert de functie van alles en geeft ze willekeurige bytes terug in plaats van false.$_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.

