
O tipo MIME e o nome de ficheiro anunciados pelo navegador não valem nada: lê o tipo real com o finfo, controla as dimensões, recodifica a imagem e regenera o nome com random_bytes. Guarda fora da raiz web, numa pasta que não executa nada. Para uma imagem em base64, descodifica em modo estrito e controla o tamanho antes de descodificar.
Receber um ficheiro enviado por um visitante é uma das funcionalidades mais arriscadas de uma aplicação web. Basta um controlo mal colocado e o servidor executa o ficheiro recebido. Este tutorial mostra o formulário, o tratamento e depois as verificações que aguentam mesmo, com os testes que provam quais é que não aguentam. Cobre também o envio de imagens em base64, corrente com os croppers JavaScript.
O formulário
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="5242880">
<label for="photo">Photo</label>
<input type="file" name="photo" id="photo" accept="image/jpeg,image/png,image/gif,image/webp" required>
<button type="submit">Envoyer</button>
<p>Formats acceptés : JPEG, PNG, GIF, WebP. 5 Mo maximum.</p>
</form>O atributo enctype="multipart/form-data" é obrigatório: sem ele, o navegador envia apenas o nome do ficheiro e $_FILES fica vazio. O atributo accept e o campo MAX_FILE_SIZE melhoram a experiência do lado do navegador, não são controlos de segurança, já que um pedido pode ser fabricado sem passar pelo formulário.
O que contém $_FILES
Um ficheiro enviado pelo campo photo chega em $_FILES['photo'], um array associativo de cinco entradas. Duas delas vêm do cliente e nunca devem servir de controlo.
| Chave | Conteúdo | Origem |
|---|---|---|
name | Nome original do ficheiro, extensão incluída | o cliente |
type | Tipo MIME anunciado | o cliente |
size | Tamanho em bytes do ficheiro recebido | o servidor |
tmp_name | Caminho do ficheiro temporário no servidor | o servidor |
error | Código de estado do envio, 0 se correu tudo bem | o servidor |
O ficheiro recebido é escrito numa pasta temporária e apagado no fim do script. Para o conservar, tens de o mover com move_uploaded_file(), ou tê-lo lido antes do fim do pedido. A função is_uploaded_file() verifica que o caminho aponta mesmo para um ficheiro vindo de um envio HTTP, o que impede desviar o tratamento para um ficheiro do sistema.
O controlo que não protege de nada
O padrão seguinte aparece na maior parte dos tutoriais, incluindo as versões anteriores deste.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- anunciado pelo navegador
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Duas falhas, medidas em PHP 8.5.10.
O valor de $_FILES['photo']['type'] vem do cliente. Nunca é verificado pelo PHP. Um envio fabricado pode anunciar o que lhe apetecer:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseO ficheiro é um script PHP e o controlo deixa-o passar.
O nome original também vem do cliente. Reutilizá-lo tal e qual para construir um caminho abre a porta a vários abusos:
'../../config.php' -> extension : php basename : config.php
'photo.php.png' -> extension : png basename : photo.php.png
'photo.png.php' -> extension : php basename : photo.png.php
'photo.pHp' -> extension : pHp basename : photo.pHpO caso photo.php.png é o mais traiçoeiro: a extensão vista pelo pathinfo() é de facto png, portanto o controlo passa, mas um Apache configurado com AddHandler em .php pode executar o ficheiro por causa da extensão intermédia. O caso photo.pHp lembra que a comparação de extensões é sensível a maiúsculas e minúsculas.
A validação que aguenta
Três princípios: o tipo lê-se no conteúdo, não no que vem anunciado, o nome é regenerado, nunca reaproveitado, o ficheiro de imagem é recodificado.
<?php
declare(strict_types=1);
const TYPES_AUTORISES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* @return array{0: bool, 1: string} sucesso e nome do ficheiro, ou falha e motivo
*/
function validerImage(
string $chemin,
int $maxOctets = 5_242_880,
int $maxPixels = 50_000_000,
): array {
if (!is_file($chemin)) {
return [false, 'fichier absent'];
}
$taille = filesize($chemin);
if ($taille === false || $taille === 0) {
return [false, 'fichier vide'];
}
if ($taille > $maxOctets) {
return [false, 'trop volumineux'];
}
// O tipo real, lido nos bytes do ficheiro
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($chemin);
if (!isset(TYPES_AUTORISES[$mime])) {
return [false, "type refusé : {$mime}"];
}
$infos = @getimagesize($chemin);
if ($infos === false) {
return [false, 'image illisible'];
}
[$largeur, $hauteur] = $infos;
if ($largeur < 1 || $hauteur < 1) {
return [false, "dimensions invalides ({$largeur}x{$hauteur})"];
}
if ($largeur * $hauteur > $maxPixels) {
return [false, 'bombe de décompression'];
}
// Recodificação: única defesa fiável contra os ficheiros poliglotas
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Nome imprevisível, e escrita direta na pasta de armazenamento.
// Passar por tempnam() e depois rename() falharia se /tmp e o armazenamento
// estivessem em dois sistemas de ficheiros diferentes, o que é o caso corrente
// em contentor.
$nom = bin2hex(random_bytes(16)) . '.' . TYPES_AUTORISES[$mime];
$destination = __DIR__ . '/../stockage/' . $nom;
$ok = match (TYPES_AUTORISES[$mime]) {
'jpg' => imagejpeg($image, $destination, 85),
'png' => imagepng($image, $destination),
'gif' => imagegif($image, $destination),
'webp' => imagewebp($image, $destination, 85),
};
if (!$ok) {
return [false, 'réencodage impossible'];
}
return [true, $nom];
}O tratamento do pedido, com os códigos de erro:
<?php
declare(strict_types=1);
require __DIR__ . '/src/upload.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Méthode non autorisée');
}
if (!isset($_FILES['photo'])) {
http_response_code(400);
exit('Aucun fichier reçu');
}
$erreur = $_FILES['photo']['error'];
if ($erreur !== UPLOAD_ERR_OK) {
$message = match ($erreur) {
UPLOAD_ERR_INI_SIZE => 'Fichier plus grand que upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'Fichier plus grand que MAX_FILE_SIZE',
UPLOAD_ERR_PARTIAL => 'Envoi interrompu',
UPLOAD_ERR_NO_FILE => 'Aucun fichier sélectionné',
UPLOAD_ERR_NO_TMP_DIR => 'Dossier temporaire absent sur le serveur',
UPLOAD_ERR_CANT_WRITE => 'Écriture impossible sur le disque',
UPLOAD_ERR_EXTENSION => 'Envoi bloqué par une extension PHP',
default => 'Erreur inconnue',
};
http_response_code(400);
exit($message);
}
// Garante que o ficheiro vem mesmo de um envio HTTP
if (!is_uploaded_file($_FILES['photo']['tmp_name'])) {
http_response_code(400);
exit('Envoi invalide');
}
[$succes, $resultat] = validerImage($_FILES['photo']['tmp_name']);
if (!$succes) {
http_response_code(422);
exit('Fichier refusé : ' . $resultat);
}
echo 'Enregistré sous ', htmlspecialchars($resultat, ENT_QUOTES, 'UTF-8');O que cada controlo apanha
Quatro ficheiros passados a validerImage(), em PHP 8.5.10 com GD 2.0.35:
| Ficheiro enviado | Resultado | Controlo acionado |
|---|---|---|
Script PHP renomeado para .png | recusado | tipo real: text/x-php |
| Poliglota: cabeçalho GIF + código PHP | recusado | dimensões (0×0 ou absurdas) |
| PNG verdadeiro 120×120 | aceite | — |
| PNG verdadeiro com PHP acrescentado no fim | aceite | recodificado, a carga desaparece |
A segunda linha merece uma pausa. Um ficheiro que começa por GIF89a seguido de código PHP é identificado como image/gif pelo finfo. A biblioteca de deteção confia na assinatura dos primeiros bytes, e essa assinatura está correta. Só os controlos de dimensões o rejeitam: consoante os bytes que se seguem ao cabeçalho, getimagesize() lê 0×0 ou dimensões absurdas, e é então o teto de píxeis que recusa o ficheiro. Uma validação que pára no finfo deixa-o passar.
A quarta linha é o caso mais frequente: uma imagem perfeitamente válida à qual foi concatenada uma carga. É aceite, e está certo, porque é mesmo uma imagem, mas o ficheiro guardado é o resultado da recodificação, não o original. A carga não sobrevive.
O armazenamento conta tanto como a validação
Nenhuma validação substitui uma pasta de armazenamento que não executa nada. A pasta tem de ficar fora da raiz web e ser servida por um script que envia o tipo de conteúdo certo.
<IfModule mod_php.c>
php_flag engine off
</IfModule>
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Header set X-Content-Type-Options "nosniff"location ^~ /stockage/ {
location ~ \.php$ { return 403; }
add_header X-Content-Type-Options "nosniff" always;
}O cabeçalho X-Content-Type-Options: nosniff impede o navegador de adivinhar o tipo de um ficheiro e de executar HTML ou JavaScript encontrado dentro de uma imagem.
Os limites do php.ini decidem antes do teu código
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
printf("%-20s %s\n", $d, ini_get($d));
}Um ficheiro maior do que post_max_size chega com $_FILES e $_POST completamente vazios: o teste isset($_FILES['photo']) falha e a mensagem de erro engana. Este caso tem de ser detetado à parte:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_POST) && empty($_FILES)
&& (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
http_response_code(413);
exit('Envoi plus grand que post_max_size (' . ini_get('post_max_size') . ')');
}Este bloco tem uma condição de aplicação que descobrimos ao testá-lo: só funciona se display_errors estiver desativado. O PHP emite o seu próprio aviso antes de executar uma única linha do script e, se esse aviso aparecer, os cabeçalhos já foram enviados.
display_errors = On -> HTTP 200 + « Warning: PHP Request Startup: POST Content-Length
of 8388808 bytes exceeds the limit of 6291456 bytes »
puis « Cannot set response code - headers already sent »
display_errors = Off -> HTTP 413 « Envoi plus grand que post_max_size (6M) »Em produção, display_errors tem de estar a Off de qualquer maneira. É mais uma razão.
Com upload_max_filesize = 5M e post_max_size = 6M, um ficheiro de 5,5 MB passa a primeira barreira e é travado pela segunda, com UPLOAD_ERR_INI_SIZE e um código 400. O post_max_size tem de continuar acima do upload_max_filesize, senão o segundo valor não tem efeito nenhum.
Receber uma imagem em base64
Os croppers JavaScript e as capturas a partir de um <canvas> enviam muitas vezes a imagem como uma string data:image/png;base64,… em vez de a enviarem como ficheiro. O princípio de validação não muda, mas acrescentam-se duas armadilhas.
A primeira: base64_decode() sem o segundo argumento aceita tudo e devolve bytes arbitrários em vez de um erro.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." — bytes arbitrários
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)A segunda: uma string base64 pesa cerca de um terço mais do que o dado que transporta. Controlar o tamanho antes de descodificar evita alocar 40 MB para depois rejeitar um ficheiro de 30.
<?php
declare(strict_types=1);
/**
* @return array{0: bool, 1: string}
*/
function enregistrerImageBase64(
string $entree,
string $dossier,
int $maxOctets = 2_097_152,
): array {
if (!preg_match('#^data:image/(jpeg|png|gif|webp);base64,#', $entree, $m)) {
return [false, 'préfixe data: absent ou type non autorisé'];
}
$encode = substr($entree, strlen($m[0]));
// O base64 pesa 4/3 do dado: filtramos antes de descodificar
if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
return [false, 'trop volumineux (avant décodage)'];
}
$binaire = base64_decode($encode, true); // modo estrito
if ($binaire === false) {
return [false, 'base64 invalide'];
}
if (strlen($binaire) > $maxOctets) {
return [false, 'trop volumineux'];
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($binaire);
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => null,
};
if ($extension === null) {
return [false, "type réel refusé : {$mime}"];
}
$infos = @getimagesizefromstring($binaire);
if ($infos === false || $infos[0] < 1 || $infos[1] < 1) {
return [false, 'image illisible'];
}
$nom = bin2hex(random_bytes(16)) . '.' . $extension;
if (file_put_contents(rtrim($dossier, '/') . '/' . $nom, $binaire, LOCK_EX) === false) {
return [false, 'écriture impossible'];
}
return [true, $nom];
}Resultados medidos em cinco entradas:
| Entrada | Resultado | Motivo |
|---|---|---|
| PNG 1×1 válido | aceite | — |
Código PHP anunciado como image/png | recusado | tipo real: text/x-php |
SVG com onload | recusado | tipo não autorizado |
| String qualquer | recusado | prefixo data: ausente |
| Base64 corrompido | recusado | descodificação estrita falhada |
O SVG merece ser excluído de propósito. É um formato XML que pode conter JavaScript e referências externas. Se for mesmo preciso aceitá-lo, tem de passar por um sanitizador dedicado e nunca ser servido a partir do mesmo domínio da aplicação.
Duas armadilhas do código que se encontra por toda a parte
O corte ingénuo de uma string data: parte do princípio de que a entrada está bem formada:
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1 se a entrada for arbitrária
$binaire = base64_decode($parties[1]); // Undefined array key 1, depois depreciação sobre nullE o nome de ficheiro gerado com uniqid() não é imprevisível: a função deriva do relógio e os seus valores sucessivos adivinham-se.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) usa o gerador criptográfico do sistema. É a única forma correta quando o nome não pode ser adivinhado.
A reter
- O tipo e o nome anunciados pelo cliente não valem nada: lê o tipo no conteúdo e regenera o nome.
- O
finfonão chega sozinho, um poliglota GIF engana-o. Acrescenta o controlo das dimensões e a recodificação. - Guarda fora da raiz web, numa pasta que não executa nada.
- Em base64: descodificação estrita, controlo do tamanho antes de descodificar, recusa do SVG.
Para devolver à interface o nome do ficheiro guardado sem abrir uma falha, vê passar variáveis de PHP para JavaScript. Um ficheiro de várias dezenas de megabytes não se trata de uma assentada: vê ler ficheiros grandes com PHP. Depois de o guardar, um redirecionamento 303 evita o reenvio do formulário ao recarregar a página. E a classe de validação apresentada aqui segue os princípios da POO em PHP.
Para a continuação, vê manipular imagens com a GD e a Imagick e o hub Desenvolvimento web.
Erros frequentes
image/png para um script PHP. O tipo real lê-se com o finfo, nunca em $_FILES['x']['type'].photo.php.png tem mesmo a extensão png para o pathinfo(), mas um Apache mal configurado pode executá-lo por causa da extensão intermédia. O nome regenera-se.GIF89a seguido de código PHP é identificado como image/gif. Só o controlo das dimensões o rejeita.bin2hex(random_bytes(16)).true, a função aceita tudo e devolve bytes arbitrários em vez de false.$_FILES e $_POST chegam vazios. E o código de deteção só funciona se display_errors estiver a Off: senão o PHP mostra o seu aviso antes do teu script e os cabeçalhos já foram enviados.

