
El tipo MIME y el nombre de archivo que anuncia el navegador no valen nada: lee el tipo real con finfo, comprueba las dimensiones, vuelve a codificar la imagen y regenera el nombre con random_bytes. Almacena fuera de la raíz web, en una carpeta que no ejecute nada. Para una imagen en base64, decodifica en modo estricto y comprueba el tamaño antes de decodificar.
Recibir un archivo enviado por un visitante es una de las funciones más arriesgadas de una aplicación web. Basta una comprobación mal colocada para que el servidor acabe ejecutando el archivo recibido. Este tutorial muestra el formulario, el tratamiento y después las validaciones que realmente aguantan, con las pruebas que demuestran cuáles no. También cubre el envío de imágenes en base64, habitual con los recortadores de imágenes en JavaScript.
El formulario
<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>El atributo enctype="multipart/form-data" es obligatorio: sin él, el navegador envía solo el nombre del archivo y $_FILES queda vacío. El atributo accept y el campo MAX_FILE_SIZE mejoran la experiencia en el navegador, no son controles de seguridad, porque cualquiera puede fabricar una petición sin pasar por el formulario.
Qué contiene $_FILES
Un archivo enviado por el campo photo llega a $_FILES['photo'], un array asociativo de cinco entradas. Dos de ellas vienen del cliente y nunca deben servir de control.
| Clave | Contenido | Origen |
|---|---|---|
name | Nombre original del archivo, extensión incluida | el cliente |
type | Tipo MIME anunciado | el cliente |
size | Tamaño en bytes del archivo recibido | el servidor |
tmp_name | Ruta del archivo temporal en el servidor | el servidor |
error | Código de estado del envío, 0 si todo ha ido bien | el servidor |
El archivo recibido se escribe en una carpeta temporal y se borra al terminar el script. Para conservarlo hay que moverlo con move_uploaded_file(), o haberlo leído antes del final de la petición. La función is_uploaded_file() comprueba que la ruta apunta de verdad a un archivo procedente de un envío HTTP, lo que impide dirigir el tratamiento hacia un archivo del sistema.
La comprobación que no protege de nada
El patrón siguiente aparece en la mayoría de los tutoriales, incluidas las versiones anteriores de este.
$autorises = ['jpg' => 'image/jpg', 'png' => 'image/png'];
$type = $_FILES['photo']['type']; // <- anunciado por el navegador
if (in_array($type, $autorises)) {
move_uploaded_file($_FILES['photo']['tmp_name'], 'upload/' . $_FILES['photo']['name']);
}Dos fallos, medidos en PHP 8.5.10.
El valor de $_FILES['photo']['type'] viene del cliente. PHP nunca lo verifica. Un envío fabricado puede anunciar cualquier cosa:
type annoncé : image/png
type réel : text/x-php
getimagesize() : falseEl archivo es un script PHP y la comprobación lo deja pasar.
El nombre original también viene del cliente. Reutilizarlo tal cual para construir una ruta abre la puerta a varios abusos:
'../../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.pHpEl caso photo.php.png es el más traicionero: la extensión que ve pathinfo() es efectivamente png, así que la comprobación pasa, pero un Apache configurado con AddHandler sobre .php puede ejecutar el archivo por culpa de su extensión intermedia. El caso photo.pHp recuerda que la comparación de extensiones distingue mayúsculas y minúsculas.
La validación que sí aguanta
Tres principios: el tipo se lee en el contenido, no en lo que anuncia el cliente, el nombre se regenera, nunca se reutiliza, el archivo de imagen se vuelve a codificar.
<?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} éxito y nombre del archivo, o fallo y 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'];
}
// El tipo real, leído en los bytes del archivo
$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'];
}
// Recodificación: la única defensa fiable contra los archivos poliglotas
$image = @imagecreatefromstring(file_get_contents($chemin));
if ($image === false) {
return [false, 'décodage impossible'];
}
// Nombre impredecible y escritura directa en la carpeta de almacenamiento.
// Pasar por tempnam() y luego rename() fallaría si /tmp y el almacenamiento
// están en dos sistemas de archivos distintos, que es el caso habitual
// en contenedores.
$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];
}El tratamiento de la petición, con los códigos de error:
<?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);
}
// Garantiza que el archivo procede realmente de un envío 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');Lo que detecta cada comprobación
Cuatro archivos pasados a validerImage(), en PHP 8.5.10 con GD 2.0.35:
| Archivo enviado | Resultado | Comprobación que salta |
|---|---|---|
Script PHP renombrado a .png | rechazado | tipo real: text/x-php |
| Poliglota: cabecera GIF + código PHP | rechazado | dimensiones (0×0 o absurdas) |
| PNG real de 120×120 | aceptado | — |
| PNG real con código PHP añadido al final | aceptado | recodificado, el payload desaparece |
La segunda línea merece una pausa. Un archivo que empieza por GIF89a seguido de código PHP es identificado como image/gif por finfo. La biblioteca de detección se fía de la firma de los primeros bytes, y esa firma es correcta. Solo los controles de dimensiones lo rechazan: según los bytes que siguen a la cabecera, getimagesize() lee 0×0 o dimensiones absurdas, y es entonces el tope de píxeles el que rechaza el archivo. Una validación que se detiene en finfo lo deja pasar.
La cuarta fila es el caso más habitual: una imagen perfectamente válida a la que se ha concatenado un payload. Se acepta, y es correcto porque es una imagen de verdad, pero el archivo almacenado es el resultado de la recodificación, no el original. El payload no sobrevive.
El almacenamiento cuenta tanto como la validación
Ninguna validación sustituye a una carpeta de almacenamiento que no ejecuta nada. La carpeta debe estar fuera de la raíz web y servirse mediante un script que envíe el tipo de contenido correcto.
<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"location ^~ /stockage/ {
location ~ \.php$ { return 403; }
add_header X-Content-Type-Options "nosniff" always;
}La cabecera X-Content-Type-Options: nosniff impide que el navegador adivine el tipo de un archivo y ejecute el HTML o el JavaScript que encuentre dentro de una imagen.
Los límites de php.ini deciden antes que tu código
foreach (['file_uploads','upload_max_filesize','post_max_size','max_file_uploads','memory_limit'] as $d) {
printf("%-20s %s\n", $d, ini_get($d));
}Un archivo más grande que post_max_size llega con un $_FILES y un $_POST completamente vacíos: la prueba isset($_FILES['photo']) falla y el mensaje de error resulta engañoso. Hay que detectar ese caso por separado:
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 bloque tiene una condición de aplicación que descubrimos al probarlo: solo funciona si display_errors está desactivado. PHP emite su propio aviso antes de ejecutar una sola línea del script, y si ese aviso se muestra, las cabeceras ya se han enviado.
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) »En producción, display_errors debe estar a Off de todas formas. Una razón más.
Con upload_max_filesize = 5M y post_max_size = 6M, un archivo de 5,5 MB supera la primera barrera y lo detiene la segunda, con UPLOAD_ERR_INI_SIZE y un código 400. post_max_size debe seguir siendo mayor que upload_max_filesize, si no, el segundo valor no tiene ningún efecto.
Recibir una imagen en base64
Los recortadores de imágenes en JavaScript y las capturas desde un <canvas> suelen enviar la imagen como una cadena data:image/png;base64,… en lugar de como un archivo. El principio de validación no cambia, pero se añaden dos trampas.
La primera: base64_decode() sin su segundo argumento acepta cualquier cosa y devuelve bytes aleatorios en lugar de un error.
var_dump(base64_decode('ceci n est pas du base64 !!')); // string(9) "q..." — bytes arbitrarios
var_dump(base64_decode('ceci n est pas du base64 !!', true)); // bool(false)La segunda: una cadena base64 pesa alrededor de un tercio más que el dato que contiene. Comprobar el tamaño antes de decodificar evita reservar 40 MB para acabar rechazando un archivo de 30.
<?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]));
// El base64 pesa 4/3 del dato: filtramos antes de decodificar
if (strlen($encode) > (int) ceil($maxOctets * 4 / 3)) {
return [false, 'trop volumineux (avant décodage)'];
}
$binaire = base64_decode($encode, true); // estricto
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 sobre cinco entradas:
| Entrada | Resultado | Motivo |
|---|---|---|
| PNG 1×1 válido | aceptado | — |
Código PHP anunciado como image/png | rechazado | tipo real: text/x-php |
SVG con onload | rechazado | tipo no permitido |
| Cadena cualquiera | rechazado | falta el prefijo data: |
| Base64 corrupto | rechazado | decodificación estricta fallida |
El SVG merece quedarse fuera de forma deliberada. Es un formato XML que puede contener JavaScript y referencias externas. Si hay que aceptarlo, debe pasar por un saneador dedicado y no servirse nunca desde el mismo dominio que la aplicación.
Dos trampas del código que circula por todas partes
El troceado ingenuo de una cadena data: da por hecho que la entrada está bien formada:
$parties = explode(';base64,', $img);
$aux = explode('image/', $parties[0]);
$type = $aux[1]; // Undefined array key 1 si la entrada es cualquier cosa
$binaire = base64_decode($parties[1]); // Undefined array key 1, y luego deprecación sobre nullY el nombre de archivo generado con uniqid() no es impredecible: la función deriva del reloj y sus valores sucesivos se adivinan.
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96bin2hex(random_bytes(16)) usa el generador criptográfico del sistema. Es la única forma correcta cuando el nombre no debe poder adivinarse.
Para recordar
- El tipo y el nombre que anuncia el cliente no valen nada: lee el tipo en el contenido y regenera el nombre.
finfono basta por sí solo: un poliglota GIF lo engaña. Añade la comprobación de dimensiones y la recodificación.- Almacena fuera de la raíz web, en una carpeta que no ejecute nada.
- En base64: decodificación estricta, comprobación del tamaño antes de decodificar y rechazo del SVG.
Para devolver a la interfaz el nombre del archivo guardado sin abrir un agujero, consulta pasar variables de PHP a JavaScript. Un archivo de varias decenas de megabytes no se trata de una sola vez: consulta leer archivos grandes con PHP. Después de guardarlo, una redirección 303 evita el reenvío del formulario al recargar la página. Y la clase de validación que se presenta aquí sigue los principios de la POO en PHP.
Para continuar, consulta manipular imágenes con GD e Imagick y el hub de Desarrollo web.
Errores frecuentes
image/png para un script PHP. El tipo real se lee con finfo, nunca en $_FILES['x']['type'].photo.php.png sí tiene la extensión png para pathinfo(), pero un Apache mal configurado puede ejecutarlo por culpa de la extensión intermedia. El nombre se regenera.GIF89a seguido de código PHP se identifica como image/gif. Solo la comprobación de dimensiones lo rechaza.bin2hex(random_bytes(16)).true, la función acepta cualquier cosa y devuelve bytes arbitrarios en lugar de false.$_FILES y $_POST llegan vacíos. Y el código de detección solo funciona si display_errors está a Off: si no, PHP muestra su aviso antes que tu script y las cabeceras ya se han enviado.

