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
<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
acceptfiltre 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é | Exemple | Fiable ? |
|---|---|---|
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 |
error | 0 | Oui : code d'erreur de l'envoi |
size | 248913 | Oui : 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'erreur | Constante | Sens |
|---|---|---|
| 0 | UPLOAD_ERR_OK | Envoi réussi |
| 1 | UPLOAD_ERR_INI_SIZE | Le fichier dépasse upload_max_filesize de php.ini |
| 2 | UPLOAD_ERR_FORM_SIZE | Le fichier dépasse le champ caché MAX_FILE_SIZE du formulaire |
| 3 | UPLOAD_ERR_PARTIAL | Le fichier n'a été reçu qu'en partie |
| 4 | UPLOAD_ERR_NO_FILE | Aucun fichier n'a été choisi |
| 6 | UPLOAD_ERR_NO_TMP_DIR | Pas de dossier temporaire sur le serveur |
| 7 | UPLOAD_ERR_CANT_WRITE | Écriture impossible sur le disque |
| 8 | UPLOAD_ERR_EXTENSION | Une extension de PHP a interrompu l'envoi |
Les limites de php.ini
| Directive | Défaut | Rôle |
|---|---|---|
file_uploads | On | Autoriser les envois de fichiers |
upload_max_filesize | 2M | Taille maximale d'un fichier |
post_max_size | 8M | Taille maximale de toute la requête POST (fichiers et champs) : doit être supérieure à upload_max_filesize |
max_file_uploads | 20 | Nombre 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
| Risque | Principe | Parade |
|---|---|---|
| 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 serveur | Vérifier le contenu réel, renommer avec une extension choisie par le serveur, stocker hors de la racine du site |
| Traversée de répertoire | Un nom de fichier contenant ../ fait écrire le fichier ailleurs que prévu, parfois par-dessus un fichier de l'application | Ne jamais utiliser le nom envoyé : générer un nom aléatoire |
| Écrasement | Deux utilisateurs envoient photo.jpg : le second remplace le premier | Nom aléatoire unique |
| Contenu actif | Un fichier HTML ou SVG contient du script ; affiché depuis le domaine de l'application, il produit une XSS stockée | Liste blanche de formats (pas de SVG ni de HTML), en-tête X-Content-Type-Options: nosniff |
| Déni de service | Des fichiers énormes ou très nombreux remplissent le disque | Limites de taille et de nombre, quotas |
| Données personnelles cachées | Les métadonnées EXIF d'une photo de téléphone contiennent parfois les coordonnées GPS du lieu de prise de vue | Réencoder l'image, ce qui supprime les métadonnées |
Valider et enregistrer le fichier
<?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 :
- Le code d'erreur est lu avant tout. Si
errorest un tableau, c'est que plusieurs fichiers ont été envoyés sous le même nom (photo[]) : on refuse. 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.finfolit les premiers octets du fichier (sa « signature ») pour déterminer son type réel, etgetimagesize()vérifie que l'image se décode. Le format détecté est comparé à une liste blanche, qui donne aussi l'extension.- 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
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: nosniffinterdit au navigateur de deviner un autre type que celui annoncé.Cache-Control: privateautorise 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
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
$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
- Le type vient du client (
$_FILES['photo']['type']) : un fichier PHP envoyé avec le type déclaréimage/jpegpasse le test. - Le nom vient du client et le dossier
uploads/est dans le script, donc sous la racine du site : un fichier nomméx.phpest enregistré tel quel et exécuté quand l'attaquant ouvreuploads/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. - 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. - Injection SQL :
$nomet$_POST['id']sont concaténés dans la requête. - XSS :
$nomest affiché sans échappement, dans l'attribut et dans le texte du lien. - 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é ?