Filtre uploads PHP por tamanho e tipo MIME usando código aberto
Um upload chamado photo.png pode conter texto simples, e o tipo de conteúdo informado pelo
navegador pode dizer qualquer coisa. Crie um endpoint PHP que verifica os bytes recebidos, aceita
somente os tipos e a faixa de tamanho que você escolher e armazena os arquivos aceitos fora do
webroot. Vamos usar o Respect\Validation para as regras de admissão e, depois, o HTML Purifier para
a tarefa separada de sanitizar um campo HTML.
Escolha as regras de upload
Este exemplo local aceita uploads JPEG, PNG e GIF não vazios de até 5 MiB, ou
5 * 1024 * 1024 = 5242880 bytes. A
extensão Fileinfo do PHP detecta um tipo MIME a partir do
arquivo temporário. O handler mede os bytes desse arquivo, ignora o nome de arquivo original e o
tipo de conteúdo do multipart, escolhe a própria extensão e retorna HTTP 201 somente depois de
armazenar o arquivo.
Essas são verificações de admissão. Elas não decodificam uma imagem, não removem conteúdo incorporado nem detectam malware. Uma imagem danificada ou um arquivo com dados extras ainda pode corresponder a uma regra de MIME. Mantenha os bytes aceitos privados até que a decodificação ou a varredura exigida pela sua aplicação seja concluída com sucesso.
O guia de tamanho de upload no PHP mostra como
encontrar o php.ini ativo e diagnosticar limites de requisição. Aqui, os limites do PHP são
propositalmente maiores que o limite da aplicação para que você possa observar a decisão de admissão.
Configure o projeto local
Use Bash no Linux, PHP 8.5.10 com Fileinfo, DOM e mbstring habilitados, Composer 2.10.3 e cURL.
Os exemplos abaixo usam Respect\Validation 3.1.2 e HTML Purifier 4.19.1. As
notas de migração da versão 3 do Respect explicam por que
exemplos mais antigos que usam Validator, max() ou resultados booleanos de validate() precisam ser
atualizados.
Execute este bloco a partir de um diretório onde você possa criar um novo projeto. Ele se recusa a
usar um diretório php-filter-demo já existente. O subshell mantém seu terminal no diretório original,
inclusive se a instalação falhar. O comando seleciona explicitamente o novo manifesto e os caminhos
locais de dependências, para que um projeto Composer que contenha este diretório ou
substituições de caminho herdadas não recebam a instalação.
(
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
)
Prossiga somente depois que a configuração for bem-sucedida. Execute os comandos a seguir sempre a
partir desse mesmo diretório pai. Salve os arquivos PHP nos caminhos mostrados abaixo; o diretório
servido será php-filter-demo/public, enquanto dependências, definições HTML em cache e uploads ficam fora
dele.
Salve o handler de upload
Salve este programa completo como php-filter-demo/public/upload.php. O PHP preenche $_FILES quando
recebe a requisição multipart; um array fabricado em um script de linha de comando não satisfaria
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.');
}
A regra between() inclui o limite exato de 5 MiB. A lista de permissões de MIME usa o resultado do
Fileinfo, não o cabeçalho multipart. O PHP documenta os
códigos de erro de upload separadamente
da validação da aplicação.
Cada requisição aceita recebe um novo ID, mesmo quando os bytes são idênticos. Se a reserva desse ID
falhar, o handler retorna 500 e deixa o diretório existente intacto. Isso importa porque
move_uploaded_file() sobrescreve um destino existente.
Os arquivos salvos com sucesso ficam em private/<id>/file.<extension> com modo 0600, dentro de um diretório
com modo 0700. O nome de arquivo original nunca se torna um caminho de armazenamento.
Execute o servidor e envie um arquivo real
Escolha uma porta local disponível; se a 8787 estiver ocupada, altere-a no comando do servidor e nas duas requisições. Inicie o servidor de desenvolvimento do 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
Deixe este terminal em execução. Em um segundo terminal, abra o mesmo diretório pai. Salve o
conteúdo a seguir como php-filter-demo/make-sample.php. Ele cria um PNG minúsculo e se recusa a substituir um
sample.png existente.
<?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);
}
Crie o arquivo e faça o upload:
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
Você deve receber HTTP 201 e um JSON contendo accepted: true, mime: "image/png", bytes: 67, um
id aleatório e um sha256. Copie o ID retornado para este comando, substituindo ID:
cmp php-filter-demo/sample.png php-filter-demo/private/ID/file.png
Nenhuma saída e status zero significam que os bytes armazenados conferem. O servidor serve apenas
public/, então o arquivo privado não tem URL de download. Para repetir o upload, execute apenas o
comando cURL; cada aceitação cria outro arquivo privado.
Agora envie um texto disfarçado de imagem. O ;type=image/png do cURL fornece o cabeçalho MIME forjado;
;filename=photo.png fornece o nome de arquivo forjado. Nenhum dos dois controla o tipo detectado:
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
Você deve receber HTTP 415 com accepted: false e code: "file_type", sem nenhum novo upload armazenado. Este
comando substitui o disguised.png da demonstração em uma nova execução. As requisições de diagnóstico
omitem o --fail do cURL para que você possa ler os corpos das respostas de rejeição; uma
transferência bem-sucedida do cURL não significa que o servidor aceitou o arquivo.
| Resposta | Significado |
|---|---|
| 201 | O arquivo passou pelas regras de admissão e foi armazenado |
| 400 | Upload ausente, malformado, parcial ou vazio |
| 413 | O limite de arquivo/requisição do PHP ou o limite de 5 MiB da aplicação o rejeitou; inspecione code |
| 415 | O tipo MIME detectado está fora da lista de permissões |
| 500 | O upload ou o armazenamento privado está indisponível; nenhuma aceitação foi informada |
O diagnóstico de tamanho da requisição usa Content-Length, que essas requisições cURL fornecem. O PHP pode
esvaziar $_FILES quando post_max_size é excedido. Sem um cabeçalho de tamanho, este handler não
consegue distinguir essa situação de um arquivo ausente; aplique limites para a requisição inteira
no servidor web da sua implantação. Pare o servidor de desenvolvimento com Ctrl+C ao terminar. Os
arquivos aceitos permanecem no diretório private/ da demonstração até que você os remova.
HTML Purifier para conteúdo HTML seguro
A sanitização de HTML transforma uma string HTML. Ela não tem nenhum papel na decisão sobre se um PNG enviado corresponde a uma regra de MIME. O HTML Purifier analisa o markup enviado e aplica uma lista de permissões de elementos e atributos.
Como usar o HTML Purifier
Salve este exemplo separado como php-filter-demo/sanitize.php. A lista de permissões preserva parágrafos,
ênfase em negrito, ênfase em itálico e quebras de linha. Ela não permite atributos, links nem
imagens. Cache.SerializerPath mantém as definições geradas pela biblioteca no cache privado do projeto.
<?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;
Execute-o a partir do diretório pai:
php php-filter-demo/sanitize.php
Saída esperada:
<p>Hello <strong>PHP</strong></p>
Adicione elementos ou atributos somente quando seu campo de texto formatado precisar deles; a
diretiva HTML.Allowed
controla essa política. Use o resultado como um fragmento de corpo HTML. Ele não serve como escape
para uma string JavaScript, um valor CSS ou um atributo HTML. Para um campo de texto simples, use
escape de saída contextual, como htmlspecialchars(), ao renderizá-lo. Ao armazenar qualquer um dos tipos
de campo, use instruções SQL preparadas; a purificação não torna segura a interpolação em SQL.
Quando usar cada biblioteca
Use o Respect\Validation quando quiser compor regras de admissão ou de dados de formulário, como no handler. Use o HTML Purifier quando um campo aceitar HTML intencionalmente. Se sua aplicação já usa o Symfony Validator, a restrição File dele oferece verificações de tamanho de arquivo e de extensão/MIME. Mantenha as restrições do Symfony no seu fluxo de validação existente em vez de instalar um segundo framework de validação para este exemplo.
O endpoint local não tem autenticação, cotas por usuário nem scanner de malware. Seu limite de 5 MiB vale para cada arquivo aceito, não para o armazenamento total nem para a memória da imagem decodificada. Antes de expô-lo, decida quem pode fazer upload e qual decodificador ou scanner precisa aprovar os bytes privados antes que eles fiquem disponíveis.
Filtragem de arquivos da Transloadit
Em um pipeline de processamento, o Robot 🤖 /file/filter seleciona arquivos por metadados para os Steps seguintes de uma Assembly. Isso é separado da decisão de armazenamento do handler PHP local. Por exemplo:
{
"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]]
}
}
}
Ambas as condições de aceitação precisam corresponder, e arquivos acima de 10 MiB são recusados.
Esta regra de metadados não garante que uma imagem seja inofensiva. O Robot também aceita condições
em JavaScript, como este valor alternativo de accepts para arquivos em orientação paisagem com
menos de 500.000 bytes:
{
"accepts": "${file.meta.width > file.meta.height && file.size < 500000}"
}
A documentação do Robot explica a precedência das condições e a cobrança adicional pela avaliação de JavaScript. Use o SDK para PHP se precisar conectar uma aplicação PHP a esse fluxo de trabalho de processamento.
