
Le type MIME et le nom de fichier annoncés par le navigateur ne valent rien : lisez le type réel avec finfo, contrôlez les dimensions, réencodez l'image et régénérez le nom avec random_bytes. Stockez hors de la racine web, dans un dossier qui n'exécute rien. Pour une image en base64, décodez en mode strict et contrôlez la taille avant de décoder.
Recevoir un fichier envoyé par un visiteur est l’une des fonctionnalités les plus risquées d’une application web. Un contrôle mal placé, et le serveur exécute le fichier reçu. Ce tutoriel montre le formulaire, le traitement, puis les vérifications qui tiennent réellement, avec les tests qui prouvent lesquelles ne tiennent pas. Il couvre aussi l’envoi d’images en base64, courant avec les recadreurs JavaScript.
Le formulaire
<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>L’attribut enctype="multipart/form-data" est obligatoire : sans lui, le navigateur envoie seulement le nom du fichier et $_FILES reste vide. L’attribut accept et le champ MAX_FILE_SIZE améliorent l’expérience côté navigateur, ils ne sont pas des contrôles de sécurité, puisqu’une requête peut être fabriquée sans passer par le formulaire.
Ce que contient $_FILES
Un fichier envoyé par le champ photo arrive dans $_FILES['photo'], un tableau associatif de cinq entrées. Deux d’entre elles viennent du client et ne doivent jamais servir de contrôle.
| Clé | Contenu | Source |
|---|---|---|
name | Nom d’origine du fichier, extension comprise | le client |
type | Type MIME annoncé | le client |
size | Taille en octets du fichier reçu | le serveur |
tmp_name | Chemin du fichier temporaire sur le serveur | le serveur |
error | Code d’état de l’envoi, 0 si tout s’est bien passé | le serveur |
Le fichier reçu est écrit dans un dossier temporaire et supprimé à la fin du script. Pour le conserver, il faut le déplacer avec move_uploaded_file(), ou l’avoir lu avant la fin de la requête. La fonction is_uploaded_file() vérifie que le chemin désigne bien un fichier issu d’un envoi HTTP, ce qui empêche de faire pointer le traitement vers un fichier système.
Le contrôle qui ne protège de rien
Le motif suivant se trouve dans la plupart des tutoriels, dont les versions précédentes de celui-ci.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- annoncé par le navigateur
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Deux failles, mesurées sur PHP 8.5.10.
La valeur $_FILES['photo']['type'] vient du client. Elle n’est jamais vérifiée par PHP. Un envoi fabriqué peut annoncer n’importe quoi :
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseLe fichier est un script PHP, le contrôle le laisse passer.
Le nom d’origine vient aussi du client. Le réutiliser tel quel pour construire un chemin ouvre la porte à plusieurs abus :
'../../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.pHpLe cas photo.php.png est le plus retors : l’extension vue par pathinfo() est bien png, donc le contrôle passe, mais un Apache configuré avec AddHandler sur .php peut exécuter le fichier à cause de son extension intermédiaire. Le cas photo.pHp rappelle que la comparaison d’extension est sensible à la casse.
La validation qui tient
Trois principes : le type se lit dans le contenu, pas dans l’annonce, le nom est régénéré, jamais réutilisé, le fichier image est réencodé.
<?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} succès et nom de fichier, ou échec et motif
*/
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'];
}
// Le type réel, lu dans les octets du fichier
$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'];
}
// Réencodage : seule défense fiable contre les fichiers polyglottes
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Nom imprévisible, et écriture directe dans le dossier de stockage.
// Passer par tempnam() puis rename() échouerait si /tmp et le stockage
// sont sur deux systèmes de fichiers différents, ce qui est le cas courant
// en conteneur.
$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];
}Le traitement de la requête, avec les codes d’erreur :
<?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);
}
// Garantit que le fichier vient bien d'un envoi HTTP
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');Ce que chaque contrôle attrape
Quatre fichiers passés à validerImage(), sur PHP 8.5.10 avec GD 2.0.35 :
| Fichier envoyé | Résultat | Contrôle déclenché |
|---|---|---|
Script PHP renommé en .png | refusé | type réel : text/x-php |
| Polyglotte : en-tête GIF + code PHP | refusé | dimensions (0×0 ou aberrantes) |
| Vrai PNG 120×120 | accepté | — |
| Vrai PNG avec du PHP ajouté à la fin | accepté | réencodé, la charge disparaît |
La deuxième ligne mérite un arrêt. Un fichier commençant par GIF89a suivi de code PHP est identifié image/gif par finfo. La bibliothèque de détection se fie à la signature des premiers octets, et cette signature est correcte. Seuls les contrôles de dimensions le rejettent : selon les octets qui suivent l’en-tête, getimagesize() lit 0×0 ou des dimensions absurdes, et c’est alors le plafond de pixels qui refuse le fichier. Une validation qui s’arrête à finfo le laisse passer.
La quatrième ligne est le cas le plus courant : une image parfaitement valide à laquelle on a concaténé une charge. Elle est acceptée, et c’est correct, puisque c’est une vraie image, mais le fichier stocké est le résultat du réencodage, pas l’original. La charge ne survit pas.
Le stockage compte autant que la validation
Aucune validation ne remplace un dossier de stockage qui n’exécute rien. Le dossier doit être hors de la racine web, et servi par un script qui envoie le bon type de contenu.
<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;
}L’en-tête X-Content-Type-Options: nosniff empêche le navigateur de deviner le type d’un fichier et d’exécuter du HTML ou du JavaScript trouvé dans une image.
Les limites de php.ini décident avant votre 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));
}Un fichier plus grand que post_max_size arrive avec un $_FILES et un $_POST entièrement vides : le test isset($_FILES['photo']) échoue et le message d’erreur est trompeur. Il faut détecter ce cas séparément :
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') . ')');
}Ce bloc a une condition d’application que nous avons découverte en le testant : il ne fonctionne que si display_errors est désactivé. PHP émet son propre avertissement avant d’exécuter la moindre ligne du script, et si cet avertissement s’affiche, les en-têtes sont déjà envoyés.
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) »En production, display_errors doit de toute façon être à Off. C’est une raison de plus.
Avec upload_max_filesize = 5M et post_max_size = 6M, un fichier de 5,5 Mo passe la première barrière et se fait arrêter par la seconde, avec UPLOAD_ERR_INI_SIZE et un code 400. post_max_size doit rester supérieur à upload_max_filesize, sinon la seconde valeur n’a aucun effet.
Recevoir une image en base64
Les recadreurs JavaScript et les captures depuis un <canvas> envoient souvent l’image comme une chaîne data:image/png;base64,… plutôt que comme un fichier. Le principe de validation ne change pas, mais deux pièges s’ajoutent.
Le premier : base64_decode() sans son second argument accepte n’importe quoi et renvoie des octets aléatoires plutôt qu’une erreur.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." — octets arbitraires
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)Le second : une chaîne base64 pèse environ un tiers de plus que la donnée qu’elle contient. Contrôler la taille avant de décoder évite d’allouer 40 Mo pour rejeter ensuite un fichier de 30.
<?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]));
// Le base64 pèse 4/3 de la donnée : on filtre avant de décoder
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];
}Résultats mesurés sur cinq entrées :
| Entrée | Résultat | Motif |
|---|---|---|
| PNG 1×1 valide | accepté | — |
Code PHP annoncé image/png | refusé | type réel : text/x-php |
SVG avec onload | refusé | type non autorisé |
| Chaîne quelconque | refusé | préfixe data: absent |
| Base64 corrompu | refusé | décodage strict en échec |
Le SVG mérite d’être exclu délibérément. C’est un format XML qui peut contenir du JavaScript et des références externes. S’il faut l’accepter, il doit être nettoyé par un assainisseur dédié, jamais servi depuis le même domaine que l’application.
Deux pièges du code qu’on trouve partout
Le découpage naïf d’une chaîne data: suppose que l’entrée est bien formée :
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1 si l'entrée est quelconque
$binaire = base64_decode($parties[1]); // Undefined array key 1, puis dépréciation sur nullEt le nom de fichier généré avec uniqid() n’est pas imprévisible : la fonction dérive de l’horloge, ses valeurs successives se devinent.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) utilise le générateur cryptographique du système. C’est la seule forme correcte quand le nom ne doit pas être devinable.
À retenir
- Le type et le nom annoncés par le client ne valent rien : lire le type dans le contenu, régénérer le nom.
finfone suffit pas seul, un polyglotte GIF le trompe. Ajouter le contrôle des dimensions et le réencodage.- Stocker hors de la racine web, dans un dossier qui n’exécute rien.
- En base64 : décodage strict, contrôle de taille avant décodage, refus du SVG.
Pour renvoyer le nom du fichier enregistré à l’interface sans ouvrir de faille, voir passer des variables de PHP à JavaScript. Un fichier reçu de plusieurs dizaines de mégaoctets ne se traite pas d’un bloc : voir lire les gros fichiers avec PHP. Après l’enregistrement, une redirection 303 évite le renvoi du formulaire au rechargement. Et la classe de validation présentée ici suit les principes de la POO en PHP.
Pour la suite, voir manipuler des images avec GD et Imagick et le hub Développement web.
Erreurs fréquentes
image/png pour un script PHP. Le type réel se lit avec finfo, jamais dans $_FILES['x']['type'].photo.php.png a bien l'extension png pour pathinfo(), mais un Apache mal configuré peut l'exécuter à cause de l'extension intermédiaire. Le nom se régénère.GIF89a suivi de code PHP est identifié image/gif. Seul le contrôle des dimensions le rejette.bin2hex(random_bytes(16)).true, la fonction accepte n'importe quoi et renvoie des octets arbitraires au lieu de false.$_FILES et $_POST arrivent vides. Et le code de détection ne fonctionne que si display_errors est à Off : sinon PHP affiche son avertissement avant votre script et les en-têtes sont déjà envoyés.

