Upload seguro de imagens em PHP

Diagrama de um upload em PHP: navegador, $_FILES e os códigos UPLOAD_ERR, as verificações em upload.php, move_uploaded_file() e a resposta em JSON, com falhas retornadas como 400 ou 413

Um endpoint de upload permite que desconhecidos gravem arquivos no seu servidor. Em PHP, o pior resultado é bem específico: um arquivo que o servidor web depois executa como PHP. Esta página percorre as verificações do rte/upload.php do exemplo, mostra o que cada uma impede e o que não consegue impedir, e termina com a regra mais importante: nada em uploads/ pode ser executado, nunca.

Não confie no que o navegador diz

$_FILES['file']['name'] e $_FILES['file']['type'] vêm ambos da requisição. Um cliente pode enviar qualquer nome e qualquer tipo. Enviamos um arquivo contendo <?php echo "hello"; ?> como holiday.png com type=image/png. Uma verificação baseada em type o teria aceitado. O exemplo ignora type por completo e examina os bytes:

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.');
}

A extensão precisa ser uma de cinco, e os primeiros bytes do arquivo precisam corresponder a ela:

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,
    };
}

Por que getimagesize() e a verificação de 0 x 0

getimagesize() lê o cabeçalho da imagem e informa o tipo e as dimensões. Ela faz parte do núcleo do PHP, então funciona sem GD nem fileinfo. Sozinha, é fácil de satisfazer: uma assinatura PNG seguida de 3 MB de zeros passou por getimagesize() como image/png, com largura e altura 0. Por isso o exemplo também exige que as duas dimensões sejam pelo menos 1.

Nenhuma das verificações torna um arquivo inofensivo. Acrescentamos <?php echo "EXECUTED"; ?> ao final de um PNG real de 1×1. Ele tem assinatura válida, dimensões válidas, e o código PHP continua lá dentro. O exemplo o armazenou, como qualquer verificação de imagem faria. O que impede que isso tenha consequências são as duas regras seguintes.

Nomes gerados

O nome armazenado é criado pelo servidor: o nome original em minúsculas, reduzido a a-z, 0-9 e -, depois oito dígitos hexadecimais aleatórios e, por fim, a extensão verificada. Red Square.png vira algo como red-square-abc60cfb.png. Assim, o servidor nunca grava um nome com um segundo ponto (shell.php.png), nunca sobrescreve um arquivo existente e nunca mantém um nome escolhido pelo cliente. Um arquivo chamado shell.php é recusado antes disso: 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}.

A caixa de diálogo da galeria após um upload recusado: um aviso em vermelho diz Upload failed for holiday.jpg.
Um script chamado holiday.jpg, recusado pelo servidor. A caixa de diálogo mostra a própria mensagem; o motivo do servidor está no corpo da resposta.

Nunca execute nada em uploads/

Esta é a regra que torna seguro errar nas outras verificações. Configure o servidor web para que ele só possa enviar os arquivos de uploads/ como bytes. No Apache, copie o uploads.htaccess do exemplo para uploads/.htaccess ou, melhor ainda, coloque as mesmas linhas no vhost, dentro 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>

A linha RemoveHandler cobre uma armadilha clássica do Apache. AddHandler application/x-httpd-php .php corresponde a qualquer extensão presente no nome, então photo.php.png seria executado como PHP. No nginx, o que importa é o prefixo ^~:

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; }

Sem ^~, um location ~ \.php$ { fastcgi_pass ...; } comum em outro ponto da configuração tem prioridade sobre um location /uploads/ simples, e /uploads/x.php é executado. Mantenha também security.limit_extensions = .php no pool do PHP-FPM, para que o FPM se recuse a executar photo.png mesmo que alguma outra regra o encaminhe.

O servidor de desenvolvimento embutido do PHP tem a mesma armadilha, em outra forma. Se um script de roteamento retorna false, o próprio servidor trata o arquivo e executa arquivos .php. O roteador do exemplo serve /uploads/ ele mesmo, somente imagens. Verificamos: /uploads/probe.php retornou 404, e /uploads/probe.php.png voltou como bytes image/png, sem ser executado.

Testamos a configuração com o servidor embutido no Windows com PHP 8.2. As regras do Apache e do nginx acima não foram executadas nessa máquina, porque nenhum dos dois servidores está instalado nela. Teste-as no seu próprio servidor: coloque um test.php em uploads/ e faça uma requisição a ele. Você deve receber 403 ou 404, nunca a saída do script.

Pastas e caminhos

O campo folder é um caminho virtual, verificado parte por parte e depois resolvido com realpath() dentro da raiz de armazenamento. Isso recusa .., caminhos absolutos, letras de unidade, barras invertidas e symlinks ou junctions que apontem para fora. O guia de pastas tem o código e o que observamos com uma junction do Windows.

O que o exemplo deixa para você

O exemplo não autentica ninguém, e as duas rotas POST não têm token CSRF. O rte-files.js envia um XMLHttpRequest simples, sem cabeçalhos extras. Por isso, um framework cujo middleware de CSRF roda em todo POST, como as rotas web do Laravel, o recusaria, a menos que você exclua essas rotas ou adicione o token você mesmo. Verifique o usuário antes de rte_root_for_request(). O guia de pastas por usuário mostra onde isso entra.

Teste

Com o projeto de exemplo deste guia rodando na porta 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."}

As duas respostas são HTTP 400. O teste de contrato compartilhado envia os mesmos tipos de arquivo (um script chamado .png, um .svg, um .php, uma pasta com travessia de diretório) e espera que cada um seja recusado: node shared/contract-test.mjs http://localhost:5201 termina com ALL 35 CHECKS PASSED.

O mesmo guia para outras plataformas

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

Leia a seguir