Upload de ficheiros em PHP: validação, segurança e base64

Upload de ficheiros em PHP: validação, segurança e base64
Resposta rápida

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

public/formulaire.html
<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.

php
$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:

code
type annoncé   : image/png
type réel      : text/x-php
getimagesize() : false

O 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:

code
'../../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.pHp

O 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.

src/upload.php
<?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:

upload.php
<?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.

stockage/.htaccess
<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"
nginx.conf
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

php
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:

php
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.

code
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.

php
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.

src/base64.php
<?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:

php
$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 null

E 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.

code
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96

bin2hex(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 finfo nã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

O tipo MIME de $_FILES vem do cliente Um envio fabricado anuncia image/png para um script PHP. O tipo real lê-se com o finfo, nunca em $_FILES['x']['type'].
Nome original reutilizado 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.
O finfo sozinho não chega Um ficheiro que começa por GIF89a seguido de código PHP é identificado como image/gif. Só o controlo das dimensões o rejeita.
uniqid() para nomear um ficheiro A função deriva do relógio e os seus valores adivinham-se. Usa bin2hex(random_bytes(16)).
base64_decode() sem modo estrito Sem o segundo argumento a true, a função aceita tudo e devolve bytes arbitrários em vez de false.
post_max_size ultrapassado $_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.
tempnam() seguido de rename() Falha se a pasta temporária e o armazenamento estiverem em dois sistemas de ficheiros diferentes, o que é corrente em contentor. Escreve diretamente no destino.
SVG aceite como imagem É XML e pode conter JavaScript. A excluir, ou a sanear e a servir a partir de outro domínio.

Base64ImagesPHPSécuritéUpload

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.