Cargas de imágenes seguras en PHP
Un endpoint de carga permite que desconocidos escriban archivos en tu servidor. En PHP, el peor resultado es muy concreto: un archivo que el servidor web acaba ejecutando como PHP. Esta página repasa las comprobaciones del rte/upload.php del ejemplo, muestra qué detiene cada una y qué no puede detener, y termina con la regla que más importa: nada de lo que hay en uploads/ debe ejecutarse nunca.
No confíes en lo que dice el navegador
$_FILES['file']['name'] y $_FILES['file']['type'] vienen ambos de la solicitud. Un cliente puede enviar cualquier nombre y cualquier tipo. Enviamos un archivo que contenía <?php echo "hello"; ?> como holiday.png con type=image/png. Una comprobación de type lo habría aceptado. El ejemplo ignora type por completo y examina los bytes:
// 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.');
}La extensión debe ser una de cinco, y los primeros bytes del archivo deben coincidir con ella:
// 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 qué getimagesize() y la comprobación de 0 x 0
getimagesize() lee el encabezado de la imagen e informa del tipo y las dimensiones. Forma parte del núcleo de PHP, así que funciona sin GD ni fileinfo. Por sí sola es fácil de engañar: una firma PNG seguida de 3 MB de ceros pasó getimagesize() como image/png, con un ancho y un alto de 0. Por eso el ejemplo también exige que ambas dimensiones sean al menos 1.
Ninguna de las dos comprobaciones vuelve inofensivo un archivo. Añadimos <?php echo "EXECUTED"; ?> al final de un PNG real de 1×1. Tiene una firma válida y dimensiones válidas, y el código PHP sigue estando dentro. El ejemplo lo guardó, como haría cualquier comprobación de imágenes. Lo que impide que eso importe son las dos reglas siguientes.
Nombres generados
El nombre guardado lo construye el servidor: el nombre original en minúsculas, reducido a a-z, 0-9 y -, luego ocho dígitos hexadecimales aleatorios y luego la extensión comprobada. Red Square.png se convierte en algo como red-square-abc60cfb.png. Así, el servidor nunca escribe un nombre con un segundo punto (shell.php.png), nunca sobrescribe un archivo existente y nunca conserva un nombre elegido por el cliente. Un archivo llamado shell.php se rechaza antes de eso: 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}.

holiday.jpg, rechazado por el servidor. El cuadro de diálogo muestra su propio mensaje; el motivo del servidor está en el cuerpo de la respuesta.Nunca ejecutes nada en uploads/
Esta es la regla que hace que equivocarse en las otras comprobaciones no sea grave. Configura el servidor web para que solo pueda enviar los archivos de uploads/ como bytes. En Apache, copia el uploads.htaccess del ejemplo a uploads/.htaccess o, mejor aún, pon las mismas líneas en el vhost, dentro de <Directory>:
# 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>La línea RemoveHandler cubre una trampa clásica de Apache. AddHandler application/x-httpd-php .php coincide con cualquier extensión de un nombre, así que photo.php.png se ejecutaría como PHP. En nginx, lo que importa es el prefijo ^~:
# ^~ : 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; }Sin ^~, un location ~ \.php$ { fastcgi_pass ...; } normal en otra parte de la configuración tiene prioridad sobre un location /uploads/ simple, y /uploads/x.php se ejecuta. Mantén también security.limit_extensions = .php en el pool de PHP-FPM, para que FPM se niegue a ejecutar photo.png aunque alguna otra regla se lo pase.
El servidor de desarrollo integrado de PHP tiene la misma trampa a su manera. Si un script de enrutamiento devuelve false, el servidor atiende el archivo por su cuenta y ejecuta los archivos .php. El router del ejemplo sirve /uploads/ él mismo, y solo imágenes. Lo comprobamos: /uploads/probe.php dio 404, y /uploads/probe.php.png volvió como bytes image/png, sin ejecutarse.
Probamos la configuración del servidor integrado en Windows con PHP 8.2. Las reglas de Apache y nginx de arriba no se ejecutaron en esa máquina, porque ninguno de los dos servidores está instalado allí. Pruébalas en tu propio servidor: pon un test.php en uploads/ y solicítalo. Deberías obtener 403 o 404, nunca su salida.
Carpetas y rutas
El campo folder es una ruta virtual que se comprueba parte por parte y luego se resuelve con realpath() dentro de la raíz de almacenamiento. Eso rechaza .., las rutas absolutas, las letras de unidad, las barras invertidas y los enlaces simbólicos o junctions que apuntan fuera. La guía de carpetas tiene el código y lo que observamos con una junction de Windows.
Lo que el ejemplo deja en tus manos
El ejemplo no autentica a nadie, y sus dos rutas POST no tienen token CSRF. rte-files.js envía un XMLHttpRequest simple sin encabezados adicionales. Por eso, un framework cuyo middleware CSRF se ejecuta en cada POST, como las rutas web de Laravel, lo rechazaría a menos que excluyas estas rutas o añadas el token tú mismo. Comprueba el usuario antes de rte_root_for_request(). La guía de carpetas por usuario muestra dónde va eso.
Pruébalo
Con el proyecto de ejemplo de esta guía en ejecución en el puerto 5201:
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."}Ambas respuestas son HTTP 400. La prueba de contrato compartida envía los mismos tipos de archivo (un script llamado .png, un .svg, un .php, una carpeta con recorrido de directorios) y espera que se rechacen todos: node shared/contract-test.mjs http://localhost:5201 termina con ALL 35 CHECKS PASSED.
La misma guía para otras plataformas
JavaScript · Next.js · ASP.NET Core · Razor Pages · MVC 5 · Web Forms