Filtrer les envois PHP par taille et type MIME avec l’open source
Un fichier envoyé nommé photo.png peut contenir du texte brut, et le type de contenu fourni par
le navigateur peut indiquer n’importe quoi. Créez un point de terminaison PHP qui vérifie les octets
reçus, n’accepte que les types et la plage de tailles que vous avez choisis, et stocke les fichiers
acceptés hors de la racine web. Nous utiliserons Respect\Validation pour les règles d’admission,
puis HTML Purifier pour la tâche distincte d’assainissement d’un champ HTML.
Choisir les règles d’envoi
Cet exemple local accepte les envois JPEG, PNG et GIF non vides jusqu’à 5 MiB, soit
5 * 1024 * 1024 = 5242880 octets. L’extension Fileinfo de PHP détecte un
type MIME à partir du fichier temporaire. Le gestionnaire mesure les octets de ce fichier, ignore le
nom de fichier d’origine et le type de contenu multipart, choisit sa propre extension et ne renvoie
HTTP 201 qu’après avoir stocké le fichier.
Ce sont des contrôles d’admission. Ils ne décodent pas d’image, ne suppriment pas de contenu intégré et ne détectent pas de logiciels malveillants. Une image endommagée ou un fichier contenant des données supplémentaires peut tout de même satisfaire une règle MIME. Gardez les octets acceptés privés tant que le décodage ou l’analyse de sécurité exigés par votre application n’ont pas réussi.
Le guide sur la taille des envois PHP explique comment
trouver le fichier php.ini actif et diagnostiquer les limites de requête. Ici, les limites de
PHP sont volontairement plus élevées que le plafond de l’application, afin que vous puissiez
observer la décision d’admission.
Configurer le projet local
Utilisez Bash sous Linux, PHP 8.5.10 avec Fileinfo, DOM et mbstring activés, Composer 2.10.3 et
cURL. Les exemples ci-dessous utilisent Respect\Validation 3.1.2 et HTML Purifier 4.19.1. Les
notes de migration vers la version 3 de Respect expliquent
pourquoi les anciens exemples utilisant Validator, max() ou des résultats booléens de validate()
doivent être mis à jour.
Exécutez ce bloc depuis un répertoire où vous pouvez créer un nouveau projet. Il refuse un
répertoire php-filter-demo existant. Le sous-shell laisse votre terminal dans son répertoire d’origine, y
compris si l’installation échoue. La commande sélectionne explicitement le nouveau manifeste et les
chemins de dépendances locaux, de sorte qu’un projet Composer englobant ou des
redéfinitions de chemins héritées ne reçoivent pas l’installation.
(
set -eu
mkdir php-filter-demo
cd php-filter-demo
printf '%s\n' '{"require": {}}' > composer.json
COMPOSER=./composer.json COMPOSER_VENDOR_DIR=vendor COMPOSER_BIN_DIR=vendor/bin \
composer require --no-interaction --no-plugins --no-scripts \
respect/validation:^3.1 ezyang/htmlpurifier:^4.19
mkdir public private cache
chmod 700 private cache
)
Poursuivez uniquement si la configuration a réussi. Exécutez toujours les commandes suivantes depuis
ce même répertoire parent. Enregistrez les fichiers PHP aux chemins indiqués ci-dessous ; le
répertoire servi sera php-filter-demo/public, tandis que les dépendances, les définitions HTML en cache et les
fichiers envoyés restent en dehors.
Enregistrer le gestionnaire d’envoi
Enregistrez ce programme complet sous php-filter-demo/public/upload.php. PHP remplit $_FILES lorsqu’il
reçoit la requête multipart ; un tableau fabriqué dans un script en ligne de commande ne
satisferait pas is_uploaded_file().
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use Respect\Validation\ValidatorBuilder as v;
const MAX_BYTES = 5 * 1024 * 1024;
header('Content-Type: application/json');
function rejectUpload(int $status, string $code, string $message): never {
http_response_code($status);
echo json_encode(['accepted' => false, 'code' => $code, 'message' => $message], JSON_THROW_ON_ERROR);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405, 'method', 'Send one file in a multipart POST request.');
}
$postLimit = ini_parse_quantity(ini_get('post_max_size'));
$contentLength = $_SERVER['CONTENT_LENGTH'] ?? null;
if ($postLimit > 0 && $contentLength !== null && (int) $contentLength > $postLimit) {
rejectUpload(413, 'request_size', 'The complete request exceeds PHP post_max_size.');
}
$file = $_FILES['upload'] ?? null;
if (array_keys($_FILES) !== ['upload'] || !is_array($file)
|| !is_int($file['error'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
rejectUpload(400, 'missing_upload', 'Send one file in the upload field, without array brackets.');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
[$status, $code, $message] = match ($file['error']) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => [413, 'upload_size', 'PHP rejected the file size.'],
UPLOAD_ERR_NO_FILE => [400, 'missing_upload', 'Choose a file to upload.'],
UPLOAD_ERR_PARTIAL => [400, 'partial_upload', 'The file arrived incomplete. Retry the upload.'],
default => [500, 'upload_unavailable', 'The upload service is unavailable.'],
};
rejectUpload($status, $code, $message);
}
if (!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400, 'invalid_upload', 'The file is not a valid HTTP upload.');
}
$directory = null;
$destination = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Cannot measure upload');
}
if ($size === 0) {
rejectUpload(400, 'empty_upload', 'The file is empty.');
}
if (!v::intType()->between(1, MAX_BYTES)->isValid($size)) {
rejectUpload(413, 'file_size', 'The file exceeds the 5 MiB application limit.');
}
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!v::in(array_keys($extensions))->isValid($mime)) {
rejectUpload(415, 'file_type', 'The detected type must be JPEG, PNG, or GIF.');
}
$hash = hash_file('sha256', $file['tmp_name']);
if ($hash === false) {
throw new RuntimeException('Cannot hash upload');
}
$id = bin2hex(random_bytes(16));
$candidate = dirname(__DIR__) . '/private/' . $id;
// Reserve a fresh directory so an existing upload cannot be replaced.
if (!@mkdir($candidate, 0700)) {
throw new RuntimeException('Cannot reserve storage');
}
$directory = $candidate;
$destination = $directory . '/file.' . $extensions[$mime];
if (!@move_uploaded_file($file['tmp_name'], $destination) || !@chmod($destination, 0600)) {
throw new RuntimeException('Cannot store upload');
}
http_response_code(201);
echo json_encode([
'accepted' => true,
'id' => $id,
'mime' => $mime,
'bytes' => $size,
'sha256' => $hash,
], JSON_THROW_ON_ERROR);
} catch (Throwable $error) {
if ($destination !== null) {
@unlink($destination);
}
if ($directory !== null) {
@rmdir($directory);
}
error_log('Upload storage failed: ' . get_class($error));
rejectUpload(500, 'upload_unavailable', 'The upload service is unavailable.');
}
La règle between() inclut la limite exacte de 5 MiB. La liste d’autorisation MIME utilise le
résultat de Fileinfo, pas l’en-tête multipart. PHP documente les
codes d’erreur d’envoi séparément de la validation
applicative.
Chaque requête acceptée reçoit un nouvel identifiant, même lorsque les octets sont identiques. Si la
réservation de cet identifiant échoue, le gestionnaire renvoie 500 et laisse le répertoire existant
intact. C’est important, car
move_uploaded_file() écrase une destination existante.
Les fichiers enregistrés avec succès restent sous private/<id>/file.<extension> avec le mode 0600, dans un
répertoire avec le mode 0700. Le nom de fichier d’origine ne devient jamais un chemin de
stockage.
Lancer le serveur et envoyer un vrai fichier
Choisissez un port local disponible ; si 8787 est occupé, modifiez-le dans la commande du serveur et dans les deux requêtes. Démarrez le serveur de développement de PHP :
php -d file_uploads=1 -d upload_max_filesize=6M -d post_max_size=7M \
-d display_errors=0 -d log_errors=1 \
-S 127.0.0.1:8787 -t php-filter-demo/public
Laissez ce terminal ouvert. Dans un second terminal, ouvrez le même répertoire parent. Enregistrez
ce qui suit sous php-filter-demo/make-sample.php. Ce script crée un minuscule PNG et refuse de remplacer un
sample.png existant.
<?php
declare(strict_types=1);
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg==', true);
$output = @fopen(__DIR__ . '/sample.png', 'xb');
if ($output === false) {
fwrite(STDERR, "Cannot create sample.png; use the existing sample or choose a fresh project.\n");
exit(1);
}
if (fwrite($output, $png) !== strlen($png) || !fclose($output)) {
fwrite(STDERR, "Cannot finish sample.png.\n");
exit(1);
}
Créez-le et envoyez-le :
php php-filter-demo/make-sample.php &&
curl -sS -i -F 'upload=@php-filter-demo/sample.png' http://127.0.0.1:8787/upload.php
Attendez-vous à un HTTP 201 et à un JSON contenant accepted: true, mime: "image/png", bytes: 67, un
id aléatoire et un sha256. Copiez l’identifiant renvoyé dans cette commande, en remplaçant ID :
cmp php-filter-demo/sample.png php-filter-demo/private/ID/file.png
L’absence de sortie et un code de retour nul signifient que les octets stockés correspondent. Le
serveur ne sert que public/, donc le fichier privé n’a pas d’URL de téléchargement. Pour répéter
l’envoi, exécutez uniquement la commande cURL ; chaque acceptation crée un autre fichier privé.
Envoyez maintenant du texte déguisé en image. Dans cURL, ;type=image/png fournit l’en-tête MIME
falsifié ; ;filename=photo.png fournit le nom de fichier falsifié. Aucun des deux ne contrôle le type
détecté :
printf '%s\n' 'This is text, not an image.' > php-filter-demo/disguised.png &&
curl -sS -i -F 'upload=@php-filter-demo/disguised.png;type=image/png;filename=photo.png' \
http://127.0.0.1:8787/upload.php
Attendez-vous à un HTTP 415 avec accepted: false et code: "file_type", sans nouveau fichier stocké. Cette
commande remplace le disguised.png de la démo lors d’une nouvelle exécution. Les requêtes de diagnostic
omettent l’option --fail de cURL afin que vous puissiez lire le corps des rejets ; un transfert
cURL réussi ne signifie pas que le serveur a accepté le fichier.
| Réponse | Signification |
|---|---|
| 201 | Le fichier a satisfait les règles d’admission et a été stocké |
| 400 | Envoi manquant, mal formé, partiel ou vide |
| 413 | Le plafond de fichier/requête de PHP ou le plafond de 5 MiB de l’application l’a rejeté ; examinez code |
| 415 | Le type MIME détecté est hors de la liste d’autorisation |
| 500 | L’envoi ou le stockage privé est indisponible ; aucune acceptation n’a été signalée |
Le diagnostic de taille de requête utilise Content-Length, que ces requêtes cURL fournissent. PHP peut
vider $_FILES lorsque post_max_size est dépassé. Sans en-tête de longueur, ce gestionnaire ne peut pas
distinguer cette situation d’un fichier manquant ; appliquez des limites sur la requête entière dans
votre serveur web déployé. Arrêtez le serveur de développement avec Ctrl+C une fois terminé. Les
fichiers acceptés restent dans le répertoire private/ de la démo jusqu’à ce que vous les supprimiez.
HTML Purifier pour un contenu HTML sûr
L’assainissement HTML transforme une chaîne HTML. Il ne joue aucun rôle pour décider si un PNG envoyé correspond à une règle MIME. HTML Purifier analyse le balisage soumis et applique une liste d’autorisation d’éléments et d’attributs.
Utiliser HTML Purifier
Enregistrez cet exemple distinct sous php-filter-demo/sanitize.php. La liste d’autorisation conserve les paragraphes,
la mise en gras, l’italique et les retours à la ligne. Elle n’autorise aucun attribut, lien ou
image. Cache.SerializerPath conserve les définitions générées par la bibliothèque dans le cache privé du projet.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,strong,em,br');
$config->set('Cache.SerializerPath', __DIR__ . '/cache');
$purifier = new HTMLPurifier($config);
$dirtyHtml = '<script>alert("bad")</script><p onclick="bad()" style="color:red">Hello <strong>PHP</strong><img src="x" onerror="bad()"></p>';
echo $purifier->purify($dirtyHtml), PHP_EOL;
Exécutez-le depuis le répertoire parent :
php php-filter-demo/sanitize.php
Sortie attendue :
<p>Hello <strong>PHP</strong></p>
N’ajoutez des éléments ou des attributs que si votre champ de texte enrichi en a besoin ; la
directive HTML.Allowed
contrôle cette politique. Utilisez le résultat comme fragment de corps HTML. Il ne s’agit pas d’un
échappement pour une chaîne JavaScript, une valeur CSS ou un attribut HTML. Pour un champ en texte
brut, utilisez un échappement de sortie contextuel tel que htmlspecialchars() lors de son affichage. Lorsque
vous stockez l’un ou l’autre type de champ, utilisez des requêtes SQL préparées ; la purification
ne rend pas l’interpolation SQL sûre.
Quand utiliser chaque bibliothèque
Utilisez Respect\Validation lorsque vous voulez composer des règles d’admission ou de données de formulaire, comme dans le gestionnaire. Utilisez HTML Purifier lorsqu’un champ accepte volontairement du HTML. Si votre application utilise déjà Symfony Validator, sa contrainte File fournit des contrôles de taille de fichier et d’extension/MIME. Conservez les contraintes de Symfony dans votre flux de validation existant plutôt que d’installer un second framework de validation pour cet exemple.
Le point de terminaison local n’a ni authentification, ni quotas par utilisateur, ni analyseur antimalware. Son plafond de 5 MiB limite chaque fichier accepté, pas le stockage total ni la mémoire des images décodées. Avant de l’exposer, décidez qui peut envoyer des fichiers et quel décodeur ou analyseur doit approuver les octets privés avant qu’ils ne deviennent disponibles.
Le filtrage de fichiers de Transloadit
Pour un pipeline de traitement, le Robot 🤖 /file/filter (English) sélectionne les fichiers selon leurs métadonnées pour les Steps suivants d’une Assembly. Cela est distinct de la décision de stockage du gestionnaire PHP local. Par exemple :
{
"steps": {
":original": {
"robot": "/upload/handle"
},
"filter_images": {
"use": ":original",
"robot": "/file/filter",
"condition_type": "and",
"accepts": [
["${file.mime}", "regex", "image"],
["${file.meta.width}", ">=", 100]
],
"declines": [["${file.size}", ">", 10485760]]
}
}
}
Les deux conditions d’acceptation doivent être satisfaites, et les fichiers de plus de 10 MiB sont
refusés. Cette règle de métadonnées n’établit pas qu’une image est inoffensive. Le Robot accepte
aussi des conditions JavaScript, comme cette autre valeur accepts pour les fichiers au format
paysage de moins de 500 000 octets :
{
"accepts": "${file.meta.width > file.meta.height && file.size < 500000}"
}
La documentation du Robot (English) explique la priorité des conditions et le coût supplémentaire de l’évaluation JavaScript. Utilisez le SDK PHP si vous devez connecter une application PHP à ce flux de traitement.
