Subir archivos en PHP: validación, seguridad e imágenes base64

Subir archivos en PHP: validación, seguridad e imágenes base64
Respuesta rápida

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

public/formulaire.html
<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.

php
$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:

code
type annoncé   : image/png
type réel      : text/x-php
getimagesize() : false

El 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:

code
'../../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.pHp

El 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.

src/upload.php
<?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:

upload.php
<?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.

stockage/.htaccess
<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"
nginx.conf
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

php
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:

php
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.

code
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.

php
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.

src/base64.php
<?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:

php
$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 null

Y el nombre de archivo generado con uniqid() no es impredecible: la función deriva del reloj y sus valores sucesivos se adivinan.

code
6a987fd6cde92 | 6a987fd6cde95 | 6a987fd6cde96

bin2hex(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.
  • finfo no 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

El tipo MIME de $_FILES viene del cliente Un envío fabricado anuncia image/png para un script PHP. El tipo real se lee con finfo, nunca en $_FILES['x']['type'].
Nombre original reutilizado 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.
finfo por sí solo no basta Un archivo que empieza por GIF89a seguido de código PHP se identifica como image/gif. Solo la comprobación de dimensiones lo rechaza.
uniqid() para nombrar un archivo La función deriva del reloj y sus valores se adivinan. Usa bin2hex(random_bytes(16)).
base64_decode() sin modo estricto Sin su segundo argumento a true, la función acepta cualquier cosa y devuelve bytes arbitrarios en lugar de false.
post_max_size superado $_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.
tempnam() y luego rename() Falla si la carpeta temporal y el almacenamiento están en dos sistemas de archivos distintos, algo habitual en contenedores. Escribe directamente en el destino.
SVG aceptado como imagen Es XML y puede contener JavaScript. Hay que excluirlo, o sanearlo y servirlo desde otro dominio.

Base64ImagesPHPSécuritéUpload

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.