Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1316 minIntermédiaire

Upload de fichier en PHP sécurisé

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • recevoir un fichier avec $_FILES et lire ses codes d'erreur ;
  • vérifier le type réel et la taille d'un fichier ;
  • stocker les fichiers hors de la racine du site et les servir par un script ;
  • reconnaître les failles d'un upload mal contrôlé.

3 exercices corrigés

Pour envoyer un fichier à un script PHP (un « upload »), un formulaire en multipart/form-data transmet le fichier, que PHP range dans un dossier temporaire et décrit dans le tableau $_FILES ; le script le vérifie, puis le déplace avec move_uploaded_file(). C'est l'une des fonctionnalités les plus risquées d'une application web : un fichier mal contrôlé peut devenir un programme exécuté par le serveur. Ce chapitre ajoute une photo à chaque matériel de Prêtmat, en appliquant toutes les vérifications nécessaires, et montre comment stocker les fichiers hors de la racine du site.

Le formulaire d'envoi

html
<form method="post" action="/materiels/4/photo" enctype="multipart/form-data">
  <input type="hidden" name="csrf" value="…">
  <label for="photo">Photo du matériel (JPEG, PNG ou WebP, 5 Mo au plus)</label>
  <input id="photo" name="photo" type="file" accept="image/jpeg,image/png,image/webp" required>
  <button>Envoyer</button>
</form>
  • enctype="multipart/form-data" est obligatoire : sans lui, le navigateur n'envoie que le nom du fichier, pas son contenu.
  • La méthode est toujours post.
  • L'attribut accept filtre les fichiers proposés par la fenêtre de choix du navigateur. C'est un confort, pas une sécurité : n'importe quel fichier peut être envoyé sans passer par ce formulaire.

Le tableau $_FILES

Après l'envoi, PHP a déjà enregistré le fichier dans un dossier temporaire. $_FILES['photo'] le décrit :

CléExempleFiable ?
name'IMG_2041.jpg'Non : nom choisi par le client, peut contenir n'importe quoi
full_path'IMG_2041.jpg'Non : chemin relatif envoyé par le navigateur (envoi de dossier)
type'image/jpeg'Non : déclaré par le client, falsifiable
tmp_name'/tmp/phpA1b2C3'Oui : chemin du fichier temporaire sur le serveur
error0Oui : code d'erreur de l'envoi
size248913Oui : taille en octets, mesurée par PHP

Le fichier temporaire est supprimé à la fin du script : s'il n'a pas été déplacé, il est perdu.

Code d'erreurConstanteSens
0UPLOAD_ERR_OKEnvoi réussi
1UPLOAD_ERR_INI_SIZELe fichier dépasse upload_max_filesize de php.ini
2UPLOAD_ERR_FORM_SIZELe fichier dépasse le champ caché MAX_FILE_SIZE du formulaire
3UPLOAD_ERR_PARTIALLe fichier n'a été reçu qu'en partie
4UPLOAD_ERR_NO_FILEAucun fichier n'a été choisi
6UPLOAD_ERR_NO_TMP_DIRPas de dossier temporaire sur le serveur
7UPLOAD_ERR_CANT_WRITEÉcriture impossible sur le disque
8UPLOAD_ERR_EXTENSIONUne extension de PHP a interrompu l'envoi

Les limites de php.ini

DirectiveDéfautRôle
file_uploadsOnAutoriser les envois de fichiers
upload_max_filesize2MTaille maximale d'un fichier
post_max_size8MTaille maximale de toute la requête POST (fichiers et champs) : doit être supérieure à upload_max_filesize
max_file_uploads20Nombre maximal de fichiers par requête

Pour accepter des photos de 5 Mo, règle par exemple upload_max_filesize = 5M et post_max_size = 6M. Attention au piège : si la requête dépasse post_max_size, PHP vide $_POST et $_FILES. Le formulaire semble envoyé sans aucune donnée, et même le jeton CSRF a disparu : la page répond « Requête refusée ». Le serveur web a souvent sa propre limite (client_max_body_size de Nginx, 1 Mo par défaut), à régler aussi.

Les risques d'un upload

RisquePrincipeParade
Fichier exécutable (web shell)Un fichier PHP déguisé en image est enregistré dans un dossier servi par le serveur web ; en ouvrant son adresse, l'attaquant fait exécuter son code par le serveurVérifier le contenu réel, renommer avec une extension choisie par le serveur, stocker hors de la racine du site
Traversée de répertoireUn nom de fichier contenant ../ fait écrire le fichier ailleurs que prévu, parfois par-dessus un fichier de l'applicationNe jamais utiliser le nom envoyé : générer un nom aléatoire
ÉcrasementDeux utilisateurs envoient photo.jpg : le second remplace le premierNom aléatoire unique
Contenu actifUn fichier HTML ou SVG contient du script ; affiché depuis le domaine de l'application, il produit une XSS stockéeListe blanche de formats (pas de SVG ni de HTML), en-tête X-Content-Type-Options: nosniff
Déni de serviceDes fichiers énormes ou très nombreux remplissent le disqueLimites de taille et de nombre, quotas
Données personnelles cachéesLes métadonnées EXIF d'une photo de téléphone contiennent parfois les coordonnées GPS du lieu de prise de vueRéencoder l'image, ce qui supprime les métadonnées

Valider et enregistrer le fichier

php
<?php

declare(strict_types=1);

// src/photos.php
const TYPES_PHOTO = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
const TAILLE_PHOTO_MAX = 5 * 1024 * 1024;                     // 5 Mo
const DOSSIER_PHOTOS = __DIR__ . '/../var/uploads/photos';    // hors de public/

final class PhotoInvalide extends RuntimeException
{
}

/** Vérifie la photo reçue, l'enregistre sous un nom aléatoire et renvoie ce nom. */
function enregistrerPhoto(array $fichier): string
{
    // 1. Un seul fichier, envoyé sans erreur
    if (!isset($fichier['error'], $fichier['tmp_name'], $fichier['size']) || !is_int($fichier['error'])) {
        throw new PhotoInvalide('Envoi invalide.');
    }
    match ($fichier['error']) {
        UPLOAD_ERR_OK => null,
        UPLOAD_ERR_NO_FILE => throw new PhotoInvalide('Choisis une photo.'),
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => throw new PhotoInvalide('La photo dépasse 5 Mo.'),
        default => throw new PhotoInvalide('L\'envoi a échoué, réessaie.'),
    };

    // 2. Le fichier vient bien d'un envoi HTTP, et sa taille est acceptable
    if (!is_uploaded_file($fichier['tmp_name'])) {
        throw new PhotoInvalide('Envoi invalide.');
    }
    if ($fichier['size'] > TAILLE_PHOTO_MAX) {
        throw new PhotoInvalide('La photo dépasse 5 Mo.');
    }

    // 3. Le contenu est réellement une image d'un format autorisé
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($fichier['tmp_name']);
    $extension = TYPES_PHOTO[$mime] ?? null;
    if ($extension === null || getimagesize($fichier['tmp_name']) === false) {
        throw new PhotoInvalide('Formats acceptés : JPEG, PNG ou WebP.');
    }

    // 4. Un nom choisi par le serveur, dans un dossier hors de la racine du site
    $nom = bin2hex(random_bytes(16)) . '.' . $extension;
    if (!move_uploaded_file($fichier['tmp_name'], DOSSIER_PHOTOS . '/' . $nom)) {
        throw new RuntimeException('Impossible d\'enregistrer la photo.');
    }

    return $nom;
}

Chaque étape bloque un risque :

  1. Le code d'erreur est lu avant tout. Si error est un tableau, c'est que plusieurs fichiers ont été envoyés sous le même nom (photo[]) : on refuse.
  2. is_uploaded_file() garantit que le chemin désigne bien un fichier reçu par HTTP, et pas un fichier du serveur glissé dans la requête. move_uploaded_file() fait la même vérification.
  3. finfo lit les premiers octets du fichier (sa « signature ») pour déterminer son type réel, et getimagesize() vérifie que l'image se décode. Le format détecté est comparé à une liste blanche, qui donne aussi l'extension.
  4. Le nom est aléatoire (32 caractères hexadécimaux) et l'extension vient de la liste blanche : ni traversée de répertoire, ni écrasement, ni extension .php.

Le dossier var/uploads/photos doit exister et être accessible en écriture par l'utilisateur du serveur web (www-data sous Debian), et seulement par lui.

Servir le fichier avec un script

Puisque les photos sont hors de public/, le navigateur ne peut pas les demander directement : une route de l'application les envoie, après avoir vérifié les droits. C'est aussi ce qui permet de réserver un fichier aux utilisateurs connectés.

php
<?php

declare(strict_types=1);

// Route GET /materiels/{id}/photo, dans MaterielControleur
public function photo(int $id): void
{
    exigerConnexion();

    $materiel = $this->depot->trouver($id);
    $chemin = $materiel?->photo === null ? null : DOSSIER_PHOTOS . '/' . basename($materiel->photo);
    if ($chemin === null || !is_file($chemin)) {
        http_response_code(404);
        return;
    }

    header('Content-Type: ' . (new finfo(FILEINFO_MIME_TYPE))->file($chemin));
    header('Content-Length: ' . filesize($chemin));
    header('X-Content-Type-Options: nosniff');
    header('Cache-Control: private, max-age=3600');
    readfile($chemin);
}

On suppose que la classe Materiel a reçu une propriété ?string $photo, remplie par depuisLigne() avec la colonne photo de la table. Dans la vue, l'image s'affiche avec <img src="/materiels/<?= $materiel->id ?>/photo" alt="Photo de <?= e($materiel->reference) ?>">.

  • Le nom du fichier vient de la base, pas de la requête ; basename() est une précaution supplémentaire qui retire tout chemin.
  • X-Content-Type-Options: nosniff interdit au navigateur de deviner un autre type que celui annoncé.
  • Cache-Control: private autorise le navigateur à garder l'image en cache, mais pas un serveur intermédiaire partagé.

Enregistrer le nom en base et remplacer une photo

Le fichier et la ligne en base doivent rester cohérents : si l'enregistrement en base échoue, on supprime le fichier qu'on vient d'écrire ; si la base est à jour, on supprime l'ancienne photo.

php
<?php

declare(strict_types=1);

// Route POST /materiels/{id}/photo, dans MaterielControleur
public function changerPhoto(int $id): void
{
    exigerRole('gestionnaire');                     // le jeton CSRF est vérifié par le contrôleur frontal

    $materiel = $this->depot->trouver($id) ?? throw new RuntimeException('Matériel introuvable.');

    try {
        $nouveau = enregistrerPhoto($_FILES['photo'] ?? []);
    } catch (PhotoInvalide $e) {
        flash('erreur', $e->getMessage());
        rediriger("/materiels/$id");
    }

    try {
        $this->depot->changerPhoto($id, $nouveau);  // UPDATE materiel SET photo = :photo WHERE id = :id
    } catch (Throwable $e) {
        unlink(DOSSIER_PHOTOS . '/' . $nouveau);    // pas de fichier orphelin
        throw $e;
    }

    if ($materiel->photo !== null) {
        @unlink(DOSSIER_PHOTOS . '/' . basename($materiel->photo));   // l'ancienne photo ne sert plus
    }

    flash('succes', 'Photo enregistrée.');
    rediriger("/materiels/$id");
}

Pour aller plus loin, on peut réencoder chaque image avec l'extension GD (imagecreatefromjpeg() puis imagejpeg()) : l'image produite ne contient que des pixels, ce qui supprime les métadonnées (dont la position GPS) et tout contenu caché dans le fichier d'origine. C'est aussi l'occasion de la redimensionner pour alléger les pages.

Exercices

Exercice 1 sur 3 Trouver les failles d'un upload Réussi À revoir

Voici le traitement d'envoi de photo d'une première version de Prêtmat. Relève au moins cinq problèmes et explique, pour le plus grave, ce qu'un attaquant peut obtenir.

php
<?php
$nom = $_FILES['photo']['name'];
$type = $_FILES['photo']['type'];
if ($type === 'image/jpeg' || $type === 'image/png') {
    move_uploaded_file($_FILES['photo']['tmp_name'], __DIR__ . '/uploads/' . $nom);
    $pdo->exec("UPDATE materiel SET photo = '$nom' WHERE id = " . $_POST['id']);
    echo "Photo enregistrée : <a href='uploads/$nom'>$nom</a>";
}
Voir la réponseMasquer la réponse de l'exercice 1
  1. Le type vient du client ($_FILES['photo']['type']) : un fichier PHP envoyé avec le type déclaré image/jpeg passe le test.
  2. Le nom vient du client et le dossier uploads/ est dans le script, donc sous la racine du site : un fichier nommé x.php est enregistré tel quel et exécuté quand l'attaquant ouvre uploads/x.php. C'est le plus grave : l'attaquant fait exécuter son propre code sur le serveur, avec les droits de l'application ; il peut lire .env, la base, et s'en servir comme point d'entrée vers le reste du réseau.
  3. Traversée de répertoire et écrasement : le nom peut contenir ../ (selon le navigateur et le serveur) ou reprendre le nom d'un fichier existant.
  4. Injection SQL : $nom et $_POST['id'] sont concaténés dans la requête.
  5. XSS : $nom est affiché sans échappement, dans l'attribut et dans le texte du lien.
  6. Aucun contrôle : ni code d'erreur, ni taille, ni droits (rôle), ni jeton CSRF, ni vérification que le matériel existe.

La correction est la fonction enregistrerPhoto() du chapitre, appelée par une route réservée au gestionnaire, avec une requête préparée et un affichage échappé.

Tu avais trouvé ?

Exercice 2 sur 3 Régler les limites Réussi À revoir

Les gestionnaires veulent joindre à chaque matériel une facture au format PDF de 10 Mo au plus, en plus de la photo de 5 Mo, dans le même formulaire. Quelles valeurs donner à upload_max_filesize et post_max_size ? Que se passe-t-il si un utilisateur envoie une facture de 30 Mo avec post_max_size = 16M ?

Voir la réponseMasquer la réponse de l'exercice 2

upload_max_filesize = 10M (la taille du plus gros fichier accepté) et post_max_size au moins égal à la somme des fichiers plus les champs : 16M laisse une marge au-dessus des 15 Mo. Le contrôle fin (5 Mo pour la photo, 10 Mo pour la facture) se fait dans le code, fichier par fichier.

Avec une facture de 30 Mo, la requête dépasse post_max_size : PHP vide $_POST et $_FILES. Le jeton CSRF manque, la requête est refusée et l'utilisateur ne comprend pas pourquoi. Pour afficher un message clair, on détecte ce cas au début du traitement : une requête POST dont $_SERVER['CONTENT_LENGTH'] est supérieur à la limite et dont $_POST est vide. Pour le PDF, la liste blanche devient ['application/pdf' => 'pdf'].

Tu avais trouvé ?

Exercice 3 sur 3 Pourquoi pas le nom d'origine ? Réussi À revoir

Un collègue trouve dommage de perdre le nom d'origine des fichiers (Facture-Dell-2025.pdf), utile pour les retrouver. Propose une solution qui le conserve sans risque.

Voir la réponseMasquer la réponse de l'exercice 3

On enregistre le fichier sous un nom aléatoire, et l'on stocke le nom d'origine en base, comme une simple donnée texte (colonne nom_origine VARCHAR(255)), validée en longueur. À l'affichage, il passe par e(). Au téléchargement, le script qui sert le fichier envoie l'en-tête Content-Disposition: attachment; filename="…" avec ce nom, après en avoir retiré les guillemets et les sauts de ligne (ou en utilisant la forme encodée filename*=UTF-8''… avec rawurlencode()). Le système de fichiers ne voit jamais le nom choisi par l'utilisateur.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Créer une API REST en PHP : renvoyer du JSONRevoir le sommaire du cours