Envoi d’images sécurisé en PHP

Schéma d’un envoi en PHP : navigateur, $_FILES et codes UPLOAD_ERR, vérifications de upload.php, move_uploaded_file() et réponse JSON, avec les échecs renvoyés en 400 ou 413

Un point de terminaison d’envoi permet à des inconnus d’écrire des fichiers sur votre serveur. En PHP, le pire scénario est bien précis : un fichier que le serveur web exécute ensuite comme du PHP. Cette page passe en revue les vérifications du rte/upload.php de l’exemple, montre ce que chacune arrête et ce qu’elle ne peut pas arrêter, et se termine par la règle la plus importante : rien dans uploads/ ne doit jamais être exécuté.

Ne pas se fier à ce que dit le navigateur

$_FILES['file']['name'] et $_FILES['file']['type'] proviennent tous deux de la requête. Un client peut envoyer n’importe quel nom et n’importe quel type. Nous avons envoyé un fichier contenant <?php echo "hello"; ?> sous le nom holiday.png avec type=image/png. Une vérification portant sur type l’aurait accepté. L’exemple ignore complètement type et examine plutôt les octets :

PHP · rte/upload.php
// The name decides the extension; the bytes must agree with it.
// $file['type'] is whatever the browser (or attacker) sent, so it is ignored.
$ext = rte_extension((string)($file['name'] ?? ''));
if (!in_array($ext, RTE_IMAGE_EXTENSIONS, true)) {
    throw new RteHttpError(400, 'Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.');
}
$types = ['jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF, 'webp' => IMAGETYPE_WEBP];
// Signature first, then getimagesize() (core PHP, no GD needed) parses the header.
// A bare signature followed by junk still parses, but as 0x0, so require real dimensions.
$info = @getimagesize($file['tmp_name']);
if (!rte_signature_matches($file['tmp_name'], $ext) || $info === false
    || $info[2] !== $types[$ext] || $info[0] < 1 || $info[1] < 1) {
    throw new RteHttpError(400, 'The file is not a valid .' . $ext . ' image.');
}

L’extension doit faire partie des cinq autorisées, et les premiers octets du fichier doivent lui correspondre :

PHP · rte/files-lib.php
// The first bytes of the file must match its extension.
function rte_signature_matches(string $file, string $ext): bool
{
    $handle = fopen($file, 'rb');
    if ($handle === false) {
        return false;
    }
    $head = (string)fread($handle, 12);
    fclose($handle);
    return match ($ext) {
        'jpg', 'jpeg' => str_starts_with($head, "\xFF\xD8\xFF"),
        'png' => str_starts_with($head, "\x89PNG\r\n\x1A\n"),
        'gif' => str_starts_with($head, 'GIF87a') || str_starts_with($head, 'GIF89a'),
        'webp' => str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP',
        default => false,
    };
}

Pourquoi getimagesize() et la vérification du 0 x 0

getimagesize() lit l’en-tête de l’image et en indique le type et les dimensions. La fonction fait partie du cœur de PHP : elle fonctionne donc sans GD ni fileinfo. Seule, elle est facile à satisfaire : une signature PNG suivie de 3 Mo de zéros a passé getimagesize() en tant que image/png, avec une largeur et une hauteur de 0. C’est pourquoi l’exemple exige aussi que les deux dimensions valent au moins 1.

Aucune de ces vérifications ne rend un fichier inoffensif. Nous avons ajouté <?php echo "EXECUTED"; ?> à la fin d’un vrai PNG de 1×1. Il a une signature valide, des dimensions valides, et le code PHP s’y trouve toujours. L’exemple l’a stocké, comme le ferait n’importe quelle vérification d’image. Ce qui rend cela sans conséquence, ce sont les deux règles suivantes.

Des noms générés

Le nom stocké est construit par le serveur : le nom d’origine en minuscules, réduit à a-z, 0-9 et -, puis huit chiffres hexadécimaux aléatoires, puis l’extension vérifiée. Red Square.png devient par exemple red-square-abc60cfb.png. Le serveur n’écrit donc jamais un nom avec un second point (shell.php.png), n’écrase jamais un fichier existant et ne conserve jamais un nom choisi par le client. Un fichier nommé shell.php est refusé avant même cette étape : 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}.

La boîte de dialogue de la galerie après un envoi refusé : un avis en rouge indique Upload failed for holiday.jpg.
Un script nommé holiday.jpg, refusé par le serveur. La boîte de dialogue affiche son propre message ; la raison donnée par le serveur figure dans le corps de la réponse.

Ne jamais rien exécuter dans uploads/

C’est la règle qui rend les erreurs dans les autres vérifications sans danger. Configurez le serveur web pour qu’il ne puisse jamais envoyer les fichiers de uploads/ autrement que sous forme d’octets. Sous Apache, copiez le fichier uploads.htaccess de l’exemple vers uploads/.htaccess, ou mieux, placez les mêmes lignes dans le vhost, à l’intérieur de <Directory> :

Apache · uploads.htaccess
# 1. Only image files can be requested. Everything else (x.php, x.phtml, .htaccess) -> 403,
#    whatever handler (mod_php, PHP-FPM, CGI) the server uses for .php.
Require all denied
<FilesMatch "(?i)\.(jpe?g|png|gif|webp)$">
    Require all granted
</FilesMatch>

# 2. Even an allowed name must not reach PHP. "AddHandler ... .php" also matches
#    "x.php.png" (mod_mime looks at every extension), so drop those mappings here.
RemoveHandler .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
RemoveType .php .phtml .phar .php3 .php4 .php5 .php7 .php8 .pht .phps
<IfModule mod_php.c>
    php_flag engine off
</IfModule>
Options -Indexes -ExecCGI -Includes

# 3. Browsers must not guess a different type from the bytes.
<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
</IfModule>

La ligne RemoveHandler couvre un piège classique d’Apache. AddHandler application/x-httpd-php .php s’applique à chaque extension présente dans un nom : photo.php.png serait donc exécuté comme du PHP. Sous nginx, c’est le préfixe ^~ qui compte :

nginx · nginx.conf.example
# ^~ : once a URL starts with /uploads/, nginx skips regex locations, so a
# "location ~ \.php$" block can never run an uploaded file.
location ^~ /uploads/ {
    deny all;
    location ~* \.(jpe?g|png|gif|webp)$ {
        allow all;
        add_header X-Content-Type-Options nosniff always;
    }
}
# Nothing else is PHP-executable (rte/files-lib.php, router.php, demo-login.php).
location ~ \.php$ { return 404; }

Sans ^~, un location ~ \.php$ { fastcgi_pass ...; } ordinaire ailleurs dans la configuration l’emporte sur un simple location /uploads/, et /uploads/x.php est exécuté. Conservez aussi security.limit_extensions = .php dans le pool PHP-FPM, pour que FPM refuse d’exécuter photo.png même si une autre règle le lui transmet.

Le serveur de développement intégré de PHP présente le même piège sous sa propre forme. Si un script de routage renvoie false, le serveur traite lui-même le fichier et exécute les fichiers .php. Le routeur de l’exemple sert lui-même /uploads/, et uniquement des images. Nous l’avons vérifié : /uploads/probe.php a donné un 404, et /uploads/probe.php.png est revenu sous forme d’octets image/png, sans être exécuté.

Nous avons testé la configuration du serveur intégré sous Windows avec PHP 8.2. Les règles Apache et nginx ci-dessus n’ont pas été exécutées sur cette machine, car aucun de ces serveurs n’y est installé. Testez-les sur votre propre serveur : placez un test.php dans uploads/ et demandez-le. Vous devez obtenir un 403 ou un 404, jamais sa sortie.

Dossiers et chemins

Le champ folder est un chemin virtuel, vérifié partie par partie puis résolu avec realpath() à l’intérieur de la racine de stockage. Cela refuse .., les chemins absolus, les lettres de lecteur, les barres obliques inverses, ainsi que les liens symboliques ou jonctions qui pointent vers l’extérieur. Le guide sur les dossiers contient le code et ce que nous avons observé avec une jonction Windows.

Ce qui reste à votre charge

L’exemple ne connecte personne, et ses deux routes POST n’ont pas de jeton CSRF. rte-files.js envoie un simple XMLHttpRequest sans en-têtes supplémentaires. Un framework dont le middleware CSRF s’applique à chaque POST, comme les routes web de Laravel, le refuserait donc, sauf si vous excluez ces routes ou ajoutez vous-même le jeton. Vérifiez l’utilisateur avant rte_root_for_request(). Le guide sur les dossiers par utilisateur montre où le faire.

Tester

Avec le projet d’exemple de ce guide en cours d’exécution sur le port 5201 :

Shell
printf '<?php echo "hello"; ?>' > holiday.png
curl -F "file=@holiday.png;type=image/png" http://localhost:5201/rte/upload
{"error":"The file is not a valid .png image."}

curl -F "file=@holiday.png;filename=shell.php" http://localhost:5201/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

Les deux réponses sont des HTTP 400. Le test de contrat commun envoie les mêmes types de fichiers (un script nommé .png, un .svg, un .php, un dossier de traversée de répertoire) et attend que chacun soit refusé : node shared/contract-test.mjs http://localhost:5201 se termine par ALL 35 CHECKS PASSED.

Le même guide pour d’autres plateformes

JavaScript · Next.js · ASP.NET Core · Razor Pages · MVC 5 · Web Forms

À lire ensuite