Voici une vérification d'upload qui n'apporte aucune sécurité :
if (in_array($file->getClientMimeType(), ['image/jpeg', 'image/png'])) {
$file->store('avatars');
}
getClientMimeType() renvoie l'en-tête Content-Type reçu avec le fichier. C'est le navigateur qui l'envoie, et c'est la personne qui téléverse qui décide de ce que le navigateur envoie. Renommez un fichier PHP en .jpg, positionnez l'en-tête, et cela passe.
Dit à voix haute, ce n'est pas surprenant. Ce qui mérite un article, c'est la raison pour laquelle l'erreur revient sans cesse, et ce n'est pas de la négligence.
Laravel donne déjà la bonne réponse, deux fois
Ces deux règles sont sûres :
$request->validate(['avatar' => 'required|file|mimes:jpg,png']);
$request->validate(['avatar' => 'required|file|mimetypes:image/jpeg,image/png']);
Je veux être précis sur le pourquoi, parce que je me suis trompé moi-même et que j'ai publié une règle d'analyse statique fondée sur la mauvaise version. Lire le code source du framework tranche la question.
validateMimes appelle $value->guessExtension(). validateMimetypes appelle $value->getMimeType(). Or UploadedFile de Symfony ne redéfinit pas getMimeType(), donc les deux aboutissent à File::getMimeType() :
public function getMimeType(): ?string
{
return MimeTypes::getDefault()->guessMimeType($this->getPathname());
}
guessMimeType($path) inspecte le fichier sur le disque. Les deux règles de validation lisent donc le contenu réel. Aucune ne lit l'en-tête du client.
UploadedFile expose bien cet en-tête, sous le nom getClientMimeType(), avec un cousin guessClientExtension() qui en dérive une extension. Aucune règle de validation ne les utilise.
Le framework offre donc deux vérifications sûres et un accesseur dangereux, séparés par le seul mot Client, et ils se suivent dans l'autocomplétion.
Le détail que presque personne ne connaît
Les deux règles appellent d'abord un helper privé :
protected function shouldBlockPhpUpload($value, $parameters)
{
if (in_array('php', $parameters)) {
return false;
}
$phpExtensions = ['php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar'];
return in_array(trim(strtolower($value->getClientOriginalExtension())), $phpExtensions);
}
Si le nom d'origine fourni par le client se termine par une extension PHP, la validation échoue quoi que dise l'analyse du contenu, sauf si vous avez explicitement autorisé php. C'est une ceinture et bretelles contre le fichier polyglotte, à la fois image valide et PHP valide.
Celui-ci lit bien une valeur fournie par le client, et c'est correct ici, parce qu'elle sert à refuser et jamais à accepter. Une entrée hostile peut servir à dire non.
Pourquoi il faut une règle qui connaît le framework
Un scanner de sécurité générique n'a aucune prise. getClientMimeType() est un appel de méthode ordinaire, et savoir si c'est une vulnérabilité dépend de ce que vous faites du résultat. Le journaliser ne pose aucun problème. L'afficher non plus. Décider de stocker un fichier à partir de sa valeur, si.
C'est ce que dit la règle que j'ai écrite :
patterns:
- pattern-either:
- pattern: in_array($F->getClientMimeType(), ...)
- pattern: $F->getClientMimeType() === $X
- pattern: $X === $F->getClientMimeType()
Elle cible les comparaisons, là où la valeur pilote une décision. Lire la valeur pour la mettre dans un log n'en est pas une, et la signaler serait du bruit.
Elle fait partie de stacksec, un petit jeu de règles Semgrep pour Laravel et Next.js, exécutable sans rien installer :
npx --yes semgrep --config https://raw.githubusercontent.com/catidegla/stacksec/main/rules .
Le registre officiel de Semgrep contient déjà des règles Laravel, dont l'injection SQL brute et l'assignation de masse, donc lancez p/php en complément et non à la place.
Le post-scriptum
La première version de cette règle signalait mimetypes: en affirmant qu'elle faisait confiance à l'en-tête client. C'est l'inverse. Elle se déclenchait sur du code correct avec une confiance élevée, et elle aurait détourné des gens d'une règle de validation sûre.
Je ne l'ai découvert qu'en vérifiant un mot-clé de recherche et en constatant que les sources contredisaient mon propre README. La correction a pris dix minutes.
L'erreur est documentée dans le REJECTED.md du dépôt plutôt que corrigée en silence.
Top comments (0)