Filtra subidas PHP por tamaño y tipo MIME con código abierto
Un archivo subido llamado photo.png puede contener texto sin formato, y el tipo
de contenido que proporciona el navegador puede indicar cualquier cosa. Crea un endpoint PHP que
examine los bytes recibidos, acepte solo los tipos y el intervalo de tamaños que elijas y almacene
los archivos aceptados fuera de la raíz web. Usaremos Respect\Validation para las reglas de
admisión y luego HTML Purifier para la tarea independiente de sanear un campo HTML.
Elige las reglas de subida
Este ejemplo local acepta subidas de archivos JPEG, PNG y GIF no vacíos de hasta 5 MiB, o
5 * 1024 * 1024 = 5242880 bytes. La
extensión Fileinfo de PHP detecta un tipo MIME a partir
del archivo temporal. El manejador mide los bytes de ese archivo, ignora el nombre original y el
tipo de contenido multipart, elige su propia extensión y devuelve HTTP 201 solo después de
almacenar el archivo.
Estas son comprobaciones de admisión. No decodifican una imagen, eliminan contenido incrustado ni detectan malware. Una imagen dañada o un archivo con datos adicionales aún pueden cumplir una regla MIME. Mantén los bytes aceptados privados hasta que se complete correctamente la decodificación o el análisis de seguridad que requiera tu aplicación.
La guía de tamaños de subida en PHP explica cómo encontrar el
php.ini activo y diagnosticar los límites de las solicitudes. Aquí, los
límites de PHP son deliberadamente superiores al límite de la aplicación para que puedas observar
la decisión de admisión.
Configura el proyecto local
Usa Bash en Linux, PHP 8.5.10 con Fileinfo, DOM y mbstring habilitados, Composer 2.10.3 y cURL.
Los siguientes ejemplos usan Respect\Validation 3.1.2 y HTML Purifier 4.19.1. Las
notas de migración a la versión 3 de Respect explican por qué
los ejemplos anteriores que usan Validator, max()
o resultados booleanos de validate() necesitan actualizarse.
Ejecuta este bloque desde un directorio donde puedas crear un proyecto nuevo. Se niega a continuar
si ya existe el directorio php-filter-demo. El subshell deja tu terminal en su
directorio original, incluso si falla la instalación. El comando selecciona explícitamente el
nuevo manifiesto y las rutas de dependencias locales, por lo que la instalación no se realiza en
un proyecto Composer contenedor ni en rutas definidas por
anulaciones de rutas heredadas.
(
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
)
Continúa solo después de que la configuración finalice correctamente. Sigue ejecutando los
siguientes comandos desde ese mismo directorio padre. Guarda los archivos PHP en las rutas que se
muestran a continuación; el directorio servido será php-filter-demo/public, mientras que
las dependencias, las definiciones HTML en caché y los archivos subidos permanecerán fuera de él.
Guarda el manejador de subidas
Guarda este programa completo como php-filter-demo/public/upload.php. PHP rellena
$_FILES cuando recibe la solicitud multipart; un array fabricado en un
script de línea de comandos no cumpliría la comprobación de
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.');
}
La regla between() incluye el límite exacto de 5 MiB. La lista de tipos MIME
permitidos usa el resultado de Fileinfo, no el encabezado multipart. PHP documenta los
códigos de error de subida por separado de la validación de
la aplicación.
Cada solicitud aceptada recibe un ID nuevo, incluso cuando los bytes son idénticos. Si falla la
reserva de ese ID, el manejador devuelve 500 y deja intacto el directorio existente. Esto importa
porque move_uploaded_file() sobrescribe un destino existente.
Los archivos aceptados permanecen en private/<id>/file.<extension> con el modo
0600, dentro de un directorio con el modo
0700. El nombre original del archivo nunca se convierte en una ruta de
almacenamiento.
Ejecuta el servidor y envía un archivo real
Elige un puerto local disponible; si el 8787 está ocupado, cámbialo en el comando del servidor y en ambas solicitudes. Inicia el servidor de desarrollo de 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
Deja esta terminal en ejecución. En una segunda terminal, abre el mismo directorio padre. Guarda
lo siguiente como php-filter-demo/make-sample.php. Crea un PNG diminuto y se niega a reemplazar un
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);
}
Créalo y súbelo:
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
Deberías recibir HTTP 201 y un JSON que contenga accepted: true,
mime: "image/png", bytes: 67, un
id aleatorio y un sha256. Copia el ID devuelto
en este comando, reemplazando ID:
cmp php-filter-demo/sample.png php-filter-demo/private/ID/file.png
La ausencia de salida y un estado cero indican que los bytes almacenados coinciden. El servidor
sirve solo public/, por lo que el archivo privado no tiene una URL de
descarga. Para repetir la subida, ejecuta únicamente el comando cURL; cada aceptación crea otro
archivo privado.
Ahora envía texto disfrazado de imagen. La opción ;type=image/png de cURL
proporciona el encabezado MIME falsificado; ;filename=photo.png proporciona el nombre
de archivo falsificado. Ninguno de los dos controla el 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
Deberías recibir HTTP 415 con accepted: false y code: "file_type",
sin que se almacene un nuevo archivo subido. Si se vuelve a ejecutar, este comando reemplaza el
disguised.png de la demostración. Las solicitudes de diagnóstico omiten
--fail de cURL para que puedas leer los cuerpos de las respuestas de
rechazo; una transferencia cURL exitosa no significa que el servidor haya aceptado el archivo.
| Respuesta | Significado |
|---|---|
| 201 | El archivo cumplió las reglas de admisión y se almacenó |
| 400 | Subida ausente, mal formada, parcial o vacía |
| 413 | El límite de archivo/solicitud de PHP o el límite de 5 MiB de la aplicación la rechazó; examina code |
| 415 | El tipo MIME detectado no está en la lista de tipos permitidos |
| 500 | La subida o el almacenamiento privado no están disponibles; no se informó de ninguna aceptación |
El diagnóstico del tamaño de la solicitud usa Content-Length, que estas
solicitudes cURL proporcionan. PHP puede vaciar $_FILES cuando se supera
post_max_size. Sin un encabezado de longitud, este manejador no puede distinguir
esa situación de la ausencia de un archivo; aplica límites a la solicitud completa en el servidor
web de tu entorno de producción. Detén el servidor de desarrollo con Ctrl+C cuando termines.
Los archivos aceptados permanecen en el directorio private/ de la
demostración hasta que los elimines.
HTML Purifier para contenido HTML seguro
El saneamiento de HTML transforma una cadena HTML. No interviene en la decisión de si un PNG subido cumple una regla MIME. HTML Purifier analiza el marcado enviado y aplica una lista de elementos y atributos permitidos.
Usa HTML Purifier
Guarda este ejemplo independiente como php-filter-demo/sanitize.php. La lista de elementos
permitidos conserva los párrafos, el énfasis en negrita, el énfasis en cursiva y los saltos de línea.
No permite atributos, enlaces ni imágenes. Cache.SerializerPath mantiene las
definiciones generadas por la biblioteca en la caché privada del proyecto.
<?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;
Ejecútalo desde el directorio padre:
php php-filter-demo/sanitize.php
Salida esperada:
<p>Hello <strong>PHP</strong></p>
Añade elementos o atributos solo cuando tu campo de texto enriquecido los necesite; la
directiva HTML.Allowed
controla esta política. Usa el resultado como un fragmento del cuerpo HTML. No constituye un escape
para una cadena JavaScript, un valor CSS ni un atributo HTML. Para un campo de texto sin formato,
usa un escape de salida contextual, como htmlspecialchars(), al mostrarlo. Cuando
almacenes cualquiera de los dos tipos de campo, usa sentencias SQL preparadas; la purificación no
hace segura la interpolación SQL.
Cuándo usar cada biblioteca
Usa Respect\Validation cuando quieras combinar reglas de admisión o de datos de formularios como en el manejador. Usa HTML Purifier cuando un campo acepte HTML de forma intencional. Si tu aplicación ya usa Symfony Validator, su restricción File proporciona comprobaciones de tamaño de archivo y de extensión/MIME. Mantén las restricciones de Symfony en tu flujo de validación existente en lugar de instalar un segundo framework de validación para este ejemplo.
El endpoint local no tiene autenticación, cuotas de usuario ni un analizador de malware. Su límite de 5 MiB se aplica a cada archivo aceptado, no al almacenamiento total ni a la memoria de la imagen decodificada. Antes de exponerlo, decide quién puede subir archivos y qué decodificador o analizador de seguridad debe aprobar los bytes privados antes de que estén disponibles.
Filtrado de archivos de Transloadit
Para un pipeline de procesamiento, 🤖 /file/filter Robot selecciona archivos por sus metadatos para los Steps posteriores de una Assembly. Esto es independiente de la decisión de almacenamiento del manejador PHP local. Por ejemplo:
{
"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 condiciones de aceptación deben cumplirse, y los archivos de más de 10 MiB se rechazan.
Esta regla de metadatos no demuestra que una imagen sea inofensiva. El Robot también acepta
condiciones JavaScript, como este valor alternativo de accepts para
archivos con orientación horizontal de menos de 500.000 bytes:
{
"accepts": "${file.meta.width > file.meta.height && file.size < 500000}"
}
La documentación del Robot explica la precedencia de las condiciones y el cargo adicional por la evaluación de JavaScript. Usa el SDK de PHP si necesitas conectar una aplicación PHP a ese flujo de procesamiento.
