Comment uploader un fichier en PHP (et recevoir une image en base64)

Comment uploader un fichier en PHP (et recevoir une image en base64)
Réponse rapide

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

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>

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.

php
$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 :

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

Le 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 :

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

Le 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é.

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} 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 :

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);
}

// 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.

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;
}

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

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));
}

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 :

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') . ')');
}

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.

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) »

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.

php
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.

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]));

    // 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 :

php
$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 null

Et 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.

code
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96

bin2hex(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.
  • finfo ne 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

Le type MIME de $_FILES vient du client Un envoi fabriqué annonce image/png pour un script PHP. Le type réel se lit avec finfo, jamais dans $_FILES['x']['type'].
Nom d'origine réutilisé 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.
finfo seul ne suffit pas Un fichier commençant par GIF89a suivi de code PHP est identifié image/gif. Seul le contrôle des dimensions le rejette.
uniqid() pour nommer un fichier La fonction dérive de l'horloge et ses valeurs se devinent. Utilisez bin2hex(random_bytes(16)).
base64_decode() non strict Sans son second argument à true, la fonction accepte n'importe quoi et renvoie des octets arbitraires au lieu de false.
post_max_size dépassé $_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.
tempnam() puis rename() Échoue si le dossier temporaire et le stockage sont sur deux systèmes de fichiers différents, ce qui est courant en conteneur. Écrire directement à destination.
SVG accepté comme image C'est du XML qui peut contenir du JavaScript. À exclure, ou à assainir et servir depuis un autre domaine.

Base64ImagesPHPSécuritéUpload

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.