Skip to content

Subida de archivos segura ​

Los formularios de subida de archivos (fotos de perfil, CVs, comprobantes) son una de las formas más directas de tomar el control de un servidor. Si un atacante logra subir un archivo .php y abrirlo desde el navegador, puede ejecutar cualquier comando. En esta lección construirás un sistema de subida a prueba de balas en PHP y en Node con multer.

Concepto ​

Imagina una oficina de correos que acepta paquetes. Si nadie revisa lo que entra, alguien puede enviar algo peligroso. Y si los paquetes se guardan en la misma sala donde trabaja el personal, el peligro está dentro.

Los riesgos principales:

RiesgoEjemplo
Ejecución remota (web shell)Subir shell.php y visitar /uploads/shell.php?cmd=ls
Doble extensión / extensión engañosafoto.php.jpg, foto.phtml, foto.PhP
Tipo MIME falsoEl navegador dice image/jpeg pero el contenido es PHP
XSS con archivosUn SVG o HTML con JavaScript dentro, servido desde tu dominio
Path traversalNombre de archivo ../../index.php que sobrescribe tu código
Denegación de servicioArchivos de 5 GB o miles de archivos para llenar el disco
Bombas de descompresiónUn ZIP de 1 MB que se expande a 10 GB

Las reglas de oro ​

  1. Lista blanca de extensiones y de tipos MIME, comprobando el contenido real del archivo.
  2. Renombra siempre el archivo con un nombre aleatorio que tú generas. Nunca uses el nombre original para guardarlo.
  3. Guarda fuera de la carpeta pública si es posible, y sirve los archivos con un script que verifica permisos.
  4. Si deben estar en una carpeta pública, desactiva la ejecución de scripts en ella.
  5. Limita el tamaño en el servidor (no solo en el formulario).
  6. Para imágenes, re-procésalas (redimensionar/recodificar) para eliminar contenido oculto y metadatos.

El tipo MIME del navegador miente

$_FILES['foto']['type'] en PHP y file.mimetype en multer vienen de lo que dice el cliente. Un atacante lo pone como image/jpeg aunque el archivo sea PHP. Nunca te fíes solo de él.

Ejemplo: subida segura en PHP ​

php
<?php
declare(strict_types=1);
require 'sesion.php';
require 'csrf.php';
requiereLogin();

const TAMANO_MAX = 2 * 1024 * 1024; // 2 MB
const TIPOS_PERMITIDOS = [
    // MIME real => extensión que usaremos
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
];
// Carpeta FUERA de public_html (por ejemplo /home/usuario/almacen/avatars)
const DIRECTORIO = __DIR__ . '/../almacen/avatars';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}
csrf_verificar();

$archivo = $_FILES['avatar'] ?? null;

// 1. ¿Llegó un único archivo sin errores?
if (!$archivo || !is_string($archivo['tmp_name']) || $archivo['error'] !== UPLOAD_ERR_OK) {
    exit('Error al subir el archivo.');
}

// 2. ¿Realmente vino por HTTP POST? (evita trucos con rutas locales)
if (!is_uploaded_file($archivo['tmp_name'])) {
    exit('Archivo inválido.');
}

// 3. Tamaño real en el servidor
if ($archivo['size'] > TAMANO_MAX || filesize($archivo['tmp_name']) > TAMANO_MAX) {
    exit('El archivo supera los 2 MB.');
}

// 4. ✅ Tipo MIME REAL leyendo los "bytes mágicos" del contenido
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($archivo['tmp_name']);

if (!array_key_exists($mime, TIPOS_PERMITIDOS)) {
    exit('Solo se permiten imágenes JPG, PNG o WEBP.');
}

// 5. Comprobación extra: ¿es una imagen válida que se puede abrir?
$info = getimagesize($archivo['tmp_name']);
if ($info === false || $info[0] > 5000 || $info[1] > 5000) {
    exit('Imagen inválida o demasiado grande.');
}

// 6. ✅ Nombre aleatorio + extensión según el MIME real (ignoramos la del usuario)
$nombre = bin2hex(random_bytes(16)) . '.' . TIPOS_PERMITIDOS[$mime];

if (!is_dir(DIRECTORIO)) {
    mkdir(DIRECTORIO, 0750, true);
}

// 7. Mover al destino definitivo
if (!move_uploaded_file($archivo['tmp_name'], DIRECTORIO . '/' . $nombre)) {
    exit('No se pudo guardar el archivo.');
}
chmod(DIRECTORIO . '/' . $nombre, 0640); // sin permiso de ejecución

// 8. Guardamos en BD solo el nombre generado
$pdo->prepare('UPDATE usuarios SET avatar = ? WHERE id = ?')
    ->execute([$nombre, $_SESSION['usuario_id']]);

echo 'Avatar actualizado';
php
<?php
// Sirve el archivo desde fuera de public_html, validando el nombre
require 'sesion.php';
requiereLogin();

$nombre = $_GET['f'] ?? '';

// Solo aceptamos el formato exacto que generamos: 32 hex + extensión permitida
if (!preg_match('/^[a-f0-9]{32}\.(jpg|png|webp)$/', $nombre)) {
    http_response_code(404);
    exit;
}

$ruta = __DIR__ . '/../almacen/avatars/' . $nombre;
if (!is_file($ruta)) {
    http_response_code(404);
    exit;
}

$tipos = ['jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp'];
header('Content-Type: ' . $tipos[pathinfo($nombre, PATHINFO_EXTENSION)]);
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' . filesize($ruta));
header('Cache-Control: private, max-age=86400');
readfile($ruta);
html
<form method="POST" action="/subir-avatar.php" enctype="multipart/form-data">
  <!-- token CSRF aquí -->
  <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" required>
  <button>Subir</button>
</form>

Re-procesar imágenes (opcional, muy recomendable) ​

Recodificar la imagen con GD elimina cualquier código oculto y los metadatos EXIF (que pueden incluir la ubicación GPS de quien tomó la foto):

php
<?php
// Crea una imagen nueva a partir de la original y la guarda limpia
$origen = match ($mime) {
    'image/jpeg' => imagecreatefromjpeg($archivo['tmp_name']),
    'image/png'  => imagecreatefrompng($archivo['tmp_name']),
    'image/webp' => imagecreatefromwebp($archivo['tmp_name']),
};

// Redimensionamos a máximo 400px de ancho
$limpia = imagescale($origen, 400);
imagewebp($limpia, DIRECTORIO . '/' . bin2hex(random_bytes(16)) . '.webp', 85);

imagedestroy($origen);
imagedestroy($limpia);

Si los archivos deben estar en una carpeta pública ​

En hosting compartido a veces no hay más remedio. Entonces bloquea la ejecución en esa carpeta:

apache
# Desactiva el motor PHP en esta carpeta
<IfModule mod_php.c>
    php_flag engine off
</IfModule>

# Bloquea cualquier archivo con extensiones ejecutables
<FilesMatch "(?i)\.(php\d?|phtml|phar|pl|py|cgi|sh|asp|aspx|jsp|html?|svg)$">
    Require all denied
</FilesMatch>

# Sin listado de carpeta
Options -Indexes

# Fuerza el tipo correcto y evita que se interprete como otra cosa
Header set X-Content-Type-Options "nosniff"
nginx
# Nada de PHP dentro de /uploads/
location ^~ /uploads/ {
    location ~* \.(php|phtml|phar)$ { deny all; }
    add_header X-Content-Type-Options "nosniff" always;
}

SVG no es una imagen inocente

Un archivo SVG es XML y puede contener JavaScript. Si lo sirves desde tu dominio y alguien lo abre directamente, ejecuta código con acceso a tu sitio (XSS). No permitas SVG de usuarios, o sírvelos con Content-Disposition: attachment o desde otro dominio.

Ejemplo: subida segura en Node con multer ​

bash
npm install multer file-type
js
import multer from 'multer';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileTypeFromBuffer } from 'file-type';

const PERMITIDOS = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/webp': 'webp',
};
// Carpeta fuera de "public/"
const DIRECTORIO = path.resolve('almacen/avatars');

// Guardamos en memoria para inspeccionar el contenido antes de escribir a disco
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 2 * 1024 * 1024, // 2 MB
    files: 1,                  // un solo archivo
    fields: 10,
  },
  fileFilter: (req, file, cb) => {
    // Primer filtro rápido (el cliente puede mentir; validamos de verdad después)
    cb(null, Object.hasOwn(PERMITIDOS, file.mimetype));
  },
});

app.post('/avatar', requiereLogin, upload.single('avatar'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'Imagen requerida (JPG, PNG o WEBP, máx. 2 MB)' });
  }

  // ✅ Tipo real a partir de los bytes mágicos
  const tipo = await fileTypeFromBuffer(req.file.buffer);
  if (!tipo || !Object.hasOwn(PERMITIDOS, tipo.mime)) {
    return res.status(400).json({ error: 'El contenido no es una imagen válida' });
  }

  // ✅ Nombre aleatorio con la extensión del tipo real
  const nombre = `${crypto.randomBytes(16).toString('hex')}.${PERMITIDOS[tipo.mime]}`;
  await fs.mkdir(DIRECTORIO, { recursive: true });
  await fs.writeFile(path.join(DIRECTORIO, nombre), req.file.buffer, { mode: 0o640 });

  await pool.execute('UPDATE usuarios SET avatar = ? WHERE id = ?', [nombre, req.session.usuarioId]);
  res.json({ ok: true });
});

// Manejo de errores de multer (archivo muy grande, etc.)
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(400).json({ error: 'Archivo inválido: ' + err.code });
  }
  next(err);
});
js
// Servir con validación del nombre, igual que en PHP
app.get('/avatars/:nombre', requiereLogin, (req, res) => {
  const { nombre } = req.params;
  if (!/^[a-f0-9]{32}\.(jpg|png|webp)$/.test(nombre)) {
    return res.status(404).end();
  }
  res.sendFile(nombre, {
    root: DIRECTORIO,          // sendFile con root impide salir de la carpeta
    headers: { 'X-Content-Type-Options': 'nosniff' },
  });
});

Para imágenes en Node

Usa sharp para redimensionar y recodificar: await sharp(req.file.buffer).resize(400).webp().toBuffer(). Elimina metadatos por defecto y garantiza que el resultado es una imagen real.

Límites en el servidor ​

ini
; php.ini: límites globales
file_uploads = On
upload_max_filesize = 5M
post_max_size = 8M
max_file_uploads = 5
nginx
# Nginx: tamaño máximo del cuerpo de la petición
client_max_body_size 5m;

Para proyectos grandes ​

  • Guarda los archivos en un almacenamiento externo (Amazon S3, Cloudflare R2, DigitalOcean Spaces) con URLs firmadas temporales.
  • Escanea con antivirus (ClamAV) documentos subidos por usuarios si los descargarán otras personas.
  • Sirve los archivos subidos desde un dominio distinto (usercontent.tusitio.com) para aislar cualquier XSS.

Ejercicios ​

  1. Enumera tres formas en que un atacante podría saltarse una validación que solo revisa que el nombre termine en .jpg.
  2. Adapta el script PHP para aceptar solo PDFs de hasta 5 MB (por ejemplo, para CVs).
  3. ¿Por qué es peligroso guardar el archivo con $_FILES['doc']['name'] aunque valides el tipo?
Ver solución
  1. (a) Nombre shell.php.jpg en servidores mal configurados que ejecutan cualquier archivo con .php en el nombre; (b) subir un .jpg que en realidad contiene código y combinarlo con una vulnerabilidad de inclusión de archivos (include $_GET['pagina']); (c) el nombre shell.php%00.jpg o variantes con mayúsculas como .pHp en validaciones que miran otra parte del nombre. Además, la extensión no dice nada del contenido.

  2. Cambios clave:

php
<?php
const TAMANO_MAX = 5 * 1024 * 1024;
const TIPOS_PERMITIDOS = ['application/pdf' => 'pdf'];
const DIRECTORIO = __DIR__ . '/../almacen/cvs';

// ...mismas comprobaciones 1 a 4...
// Eliminamos getimagesize() y comprobamos la firma del PDF:
$cabecera = file_get_contents($archivo['tmp_name'], false, null, 0, 5);
if ($cabecera !== '%PDF-') {
    exit('El archivo no es un PDF válido.');
}
$nombre = bin2hex(random_bytes(16)) . '.pdf';
// Al servirlo: header('Content-Disposition: attachment; filename="cv.pdf"');
  1. Porque el nombre lo controla el atacante: puede incluir ../ para escribir fuera de la carpeta, sobrescribir archivos de otros usuarios con el mismo nombre, usar caracteres que rompen el sistema de archivos o nombres que contienen HTML y producen XSS al listarse. Además revela información personal en la URL.

Resumen ​

  • Nunca confíes en la extensión ni en el MIME que envía el navegador: inspecciona el contenido con finfo o file-type.
  • Renombra con nombres aleatorios y extensión según el tipo real.
  • Guarda fuera de la carpeta pública y sirve con un script que valida.
  • Si la carpeta es pública, desactiva la ejecución de scripts.
  • Limita tamaño y cantidad en el servidor; recodifica las imágenes.
  • Cuidado con SVG y HTML subidos por usuarios.

Academia Web — aprende, copia y construye.