Permitir que los usuarios suban ficheros es una funcionalidad habitual en aplicaciones web, pero también una de las más peligrosas si no se implementa correctamente. PHP gestiona las subidas a través de la superglobal $_FILES y la función move_uploaded_file(). Validar el tipo real del fichero, generar nombres seguros y almacenar fuera del document root son las tres medidas básicas para evitar ataques.
La superglobal $_FILES
Cuando un formulario con enctype="multipart/form-data" envía un fichero, PHP lo almacena temporalmente en el servidor y expone su información en $_FILES. Cada campo de tipo file genera un subarray con cinco claves.
<?php // Estructura de $_FILES para un campo llamado 'documento' // $_FILES['documento'] => [ // 'name' => 'informe.pdf', // nombre original del fichero // 'type' => 'application/pdf', // tipo MIME declarado por el navegador (NO fiable) // 'tmp_name' => '/tmp/phpA1B2C3', // ruta temporal en el servidor // 'error' => UPLOAD_ERR_OK, // código de error (0 = sin error) // 'size' => 204800, // tamaño en bytes // ] // Subida de varios ficheros con el mismo campo (campo[]) // $_FILES['imagenes']['name'][0], $_FILES['imagenes']['tmp_name'][0], etc. ?>
Constantes de error de subida
El campo error de $_FILES indica si la subida se completó con éxito o el motivo del fallo. Siempre hay que comprobarlo antes de procesar el fichero.
<?php
function descripcionErrorSubida(int $codigo): string
{
return match($codigo) {
UPLOAD_ERR_OK => 'Sin error.',
UPLOAD_ERR_INI_SIZE => 'El fichero supera upload_max_filesize en php.ini.',
UPLOAD_ERR_FORM_SIZE => 'El fichero supera MAX_FILE_SIZE del formulario.',
UPLOAD_ERR_PARTIAL => 'El fichero solo se subió parcialmente.',
UPLOAD_ERR_NO_FILE => 'No se subió ningún fichero.',
UPLOAD_ERR_NO_TMP_DIR => 'Falta el directorio temporal.',
UPLOAD_ERR_CANT_WRITE => 'Error al escribir en disco.',
UPLOAD_ERR_EXTENSION => 'Una extensión de PHP interrumpió la subida.',
default => 'Error desconocido.',
};
}
$error = $_FILES['documento']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($error !== UPLOAD_ERR_OK) {
throw new RuntimeException(descripcionErrorSubida($error));
}
?>
Validar el tipo MIME real con finfo_file()
El campo type de $_FILES lo envía el navegador y puede falsificarse. Para conocer el tipo real del fichero hay que inspeccionarlo con la extensión fileinfo, que lee la firma binaria del fichero (magic bytes).
<?php
function validarTipoMIME(string $tmpPath, array $tiposPermitidos): bool
{
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipoReal = $finfo->file($tmpPath);
return in_array($tipoReal, $tiposPermitidos, true);
}
// Tipos permitidos para imágenes
$tiposImagenes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$tmpPath = $_FILES['imagen']['tmp_name'];
if (!is_uploaded_file($tmpPath)) {
throw new RuntimeException('El fichero no proviene de una subida real.');
}
if (!validarTipoMIME($tmpPath, $tiposImagenes)) {
throw new RuntimeException('Solo se permiten imágenes JPEG, PNG, GIF o WebP.');
}
// Validar también el tamaño máximo (2 MB)
$tamañoMax = 2 * 1024 * 1024;
if ($_FILES['imagen']['size'] > $tamañoMax) {
throw new RuntimeException('La imagen no puede superar los 2 MB.');
}
?>
Nombres seguros con random_bytes()
Guardar el fichero con el nombre original del usuario es peligroso: puede contener caracteres especiales, sobreescribir ficheros existentes o revelar información. Lo correcto es generar un nombre aleatorio e impredecible.
<?php
function generarNombreSeguro(string $extensionPermitida): string
{
// 16 bytes = 32 caracteres hexadecimales
return bin2hex(random_bytes(16)) . '.' . $extensionPermitida;
}
// Extraer la extensión del tipo MIME, no del nombre original
function extensionDesdeMIME(string $tipoMIME): string
{
return match($tipoMIME) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => throw new InvalidArgumentException("MIME no soportado: $tipoMIME"),
};
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipoReal = $finfo->file($_FILES['imagen']['tmp_name']);
$extension = extensionDesdeMIME($tipoReal);
$nombre = generarNombreSeguro($extension); // ej: 'a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5.jpg'
?>
Almacenar fuera del document root
Si el directorio de subidas está dentro del document root, un atacante que consiga subir un fichero PHP podría ejecutarlo directamente desde el navegador. Lo más seguro es guardar los ficheros en un directorio que el servidor web no sirva directamente y enviarlos a través de un script PHP cuando sea necesario.
<?php
// Directorio FUERA del document root (document root es /var/www/html/public)
define('UPLOAD_DIR', '/var/www/uploads/');
function procesarSubida(array $fichero): string
{
if ($fichero['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Error en la subida.');
}
$tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipoReal = $finfo->file($fichero['tmp_name']);
if (!in_array($tipoReal, $tiposPermitidos, true)) {
throw new RuntimeException('Tipo de fichero no permitido.');
}
$extension = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$tipoReal];
$nombreFinal = bin2hex(random_bytes(16)) . '.' . $extension;
$rutaFinal = UPLOAD_DIR . $nombreFinal;
if (!move_uploaded_file($fichero['tmp_name'], $rutaFinal)) {
throw new RuntimeException('No se pudo mover el fichero.');
}
// Ajustar permisos
chmod($rutaFinal, 0644);
return $nombreFinal; // guardar en BD para servir después
}
$nombreGuardado = procesarSubida($_FILES['imagen']);
?>
La documentación oficial sobre subida de ficheros en PHP incluye la configuración recomendada de php.ini, el manejo de subidas múltiples y las consideraciones de seguridad en entornos de producción.
