📁 File Uploads seguros — validacion y almacenamiento

Actualizado: 2024-12-01

Los uploads de archivos son uno de los vectores de ataque mas peligrosos. Un atacante podria subir un archivo PHP y ejecutarlo en el servidor. La validacion estricta y el almacenamiento seguro son imprescindibles.

#Reglas de seguridad para uploads

ReglaRazon
Nunca usar el nombre original del archivoPuede contener caracteres peligrosos o exploits
Verificar MIME type con finfo, no con extensionLa extension puede ser falsa (.php.jpg)
Establecer tamano maximo explicitoPreviene ataques de denegacion de servicio
Almacenar fuera del webroot si es posiblePreviene ejecucion directa del archivo
Agregar index.php al directorio de uploadsPreviene listado de directorio
Nunca ejecutar archivos subidosUsar readfile() para servirlos
Regenerar nombre con random_bytes()Nombre impredecible evita acceso directo

#Validacion de archivos

Funcion de validacion completa de uploads
php
<?php

/**
 * Valida un archivo subido y lo mueve al directorio de uploads.
 *
 * @param array  $file         El elemento de $_FILES['campo']
 * @param array  $allowedMimes Lista de MIME types permitidos
 * @param int    $maxBytes     Tamano maximo en bytes
 * @param string $uploadDir    Directorio de destino (ruta absoluta)
 * @return string|false  Nombre del archivo guardado, o false si falla
 */
function secureUpload(array $file, array $allowedMimes, int $maxBytes, string $uploadDir)
{
    // 1. Verificar errores de PHP en el upload
    if ($file['error'] !== UPLOAD_ERR_OK) {
        $errors = [
            UPLOAD_ERR_INI_SIZE   => 'Archivo demasiado grande (php.ini)',
            UPLOAD_ERR_FORM_SIZE  => 'Archivo demasiado grande (formulario)',
            UPLOAD_ERR_PARTIAL    => 'Upload incompleto',
            UPLOAD_ERR_NO_FILE    => 'No se selecciono ningun archivo',
            UPLOAD_ERR_NO_TMP_DIR => 'Falta directorio temporal',
            UPLOAD_ERR_CANT_WRITE => 'Error de escritura en disco',
        ];
        throw new \RuntimeException($errors[$file['error']] ?? 'Error desconocido');
    }

    // 2. Verificar que es un upload real (no una ruta inyectada)
    if (!is_uploaded_file($file['tmp_name'])) {
        throw new \RuntimeException('Archivo no es un upload valido');
    }

    // 3. Verificar tamano
    if ($file['size'] > $maxBytes) {
        throw new \RuntimeException('Archivo demasiado grande: ' . round($file['size']/1024/1024, 2) . ' MB');
    }

    // 4. Verificar MIME type real (NO la extension, NO $file['type'])
    $finfo    = new \finfo(FILEINFO_MIME_TYPE);
    $realMime = $finfo->file($file['tmp_name']);
    if (!in_array($realMime, $allowedMimes, true)) {
        throw new \RuntimeException('Tipo de archivo no permitido: ' . $realMime);
    }

    // 5. Generar nombre de archivo seguro y aleatorio
    $ext      = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    $ext      = preg_replace('/[^a-z0-9]/', '', $ext); // Solo alfanumerico
    $filename = bin2hex(random_bytes(16)) . '.' . $ext; // Nombre impredecible

    // 6. Crear directorio si no existe y protegerlo
    if (!is_dir($uploadDir)) {
        mkdir($uploadDir, 0755, true);
        // Prevenir listado de directorio y ejecucion de PHP
        file_put_contents($uploadDir . 'index.php', '<?php header("Location: ../");');
        file_put_contents($uploadDir . '.htaccess', "Options -Indexes\nphp_flag engine off");
    }

    // 7. Mover el archivo
    $destination = $uploadDir . $filename;
    if (!move_uploaded_file($file['tmp_name'], $destination)) {
        throw new \RuntimeException('Error al guardar el archivo');
    }

    return $filename;
}

#Upload de imagenes seguro

Upload de imagenes con re-codificacion
php
<?php

/**
 * Upload de imagen con re-codificacion via GD para eliminar metadatos
 * y posibles payloads embebidos en la imagen.
 */
private function uploadImage(array $file): string
{
    $allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    $maxSize      = 5 * 1024 * 1024; // 5MB
    $uploadDir    = _PS_MODULE_DIR_ . $this->name . '/uploads/img/';

    // Validar
    $filename = secureUpload($file, $allowedMimes, $maxSize, $uploadDir);
    $filepath = $uploadDir . $filename;

    // ── Re-codificar la imagen con GD para eliminar payloads ──
    $finfo    = new \finfo(FILEINFO_MIME_TYPE);
    $realMime = $finfo->file($filepath);

    $maxW = 1920;
    $maxH = 1920;

    switch ($realMime) {
        case 'image/jpeg':
            $source = imagecreatefromjpeg($filepath);
            break;
        case 'image/png':
            $source = imagecreatefrompng($filepath);
            break;
        case 'image/gif':
            $source = imagecreatefromgif($filepath);
            break;
        default:
            unlink($filepath);
            throw new \RuntimeException('Tipo de imagen no soportado');
    }

    if (!$source) {
        unlink($filepath);
        throw new \RuntimeException('Imagen corrupta o invalida');
    }

    // Redimensionar si es demasiado grande
    $w = imagesx($source);
    $h = imagesy($source);
    if ($w > $maxW || $h > $maxH) {
        $ratio  = min($maxW / $w, $maxH / $h);
        $newW   = (int) ($w * $ratio);
        $newH   = (int) ($h * $ratio);
        $resized = imagecreatetruecolor($newW, $newH);
        imagecopyresampled($resized, $source, 0, 0, 0, 0, $newW, $newH, $w, $h);
        imagedestroy($source);
        $source = $resized;
    }

    // Re-guardar (elimina metadatos EXIF y posibles payloads)
    imagejpeg($source, $filepath, 85);
    imagedestroy($source);

    return $filename;
}

#Upload de documentos PDF/CSV

Upload de documentos no-imagen
php
<?php

// ── Para PDF y CSV — almacenar fuera del webroot ──
$uploadDir = dirname(_PS_ROOT_DIR_) . '/uploads/' . $this->name . '/docs/';
// o: /var/data/prestashop-uploads/{module}/ (fuera del document root)

$allowedMimes = [
    'application/pdf',
    'text/csv',
    'text/plain',
    'application/vnd.ms-excel',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];

try {
    $filename = secureUpload(
        $_FILES['document'],
        $allowedMimes,
        10 * 1024 * 1024, // 10MB
        $uploadDir
    );

    // Guardar referencia en BD
    Configuration::updateValue('MYMODULE_UPLOADED_DOC', $filename);

} catch (\RuntimeException $e) {
    $this->errors[] = $e->getMessage();
}

#Servir archivos de forma segura

Servir archivos sin exponer la ruta real
php
<?php

// En un FrontController o AdminController:
// Nunca redirigir directamente a la URL del archivo
// En cambio, servir a traves de PHP para verificar permisos

public function initContent(): void
{
    $filename = Tools::getValue('file');

    // Validar: solo alfanumerico y extension conocida
    if (!preg_match('/^[a-f0-9]{32}\.(pdf|csv|jpg|png)$/', $filename)) {
        header('HTTP/1.1 400 Bad Request');
        exit();
    }

    // Verificar que el usuario tiene acceso a este archivo
    if (!$this->userHasAccessToFile($filename)) {
        header('HTTP/1.1 403 Forbidden');
        exit();
    }

    $filepath = $uploadDir . $filename;
    if (!file_exists($filepath)) {
        header('HTTP/1.1 404 Not Found');
        exit();
    }

    // Servir el archivo
    $finfo    = new \finfo(FILEINFO_MIME_TYPE);
    $mime     = $finfo->file($filepath);
    header('Content-Type: ' . $mime);
    header('Content-Length: ' . filesize($filepath));
    header('Content-Disposition: attachment; filename="document.pdf"');
    header('Cache-Control: no-cache, no-store, must-revalidate');
    readfile($filepath);
    exit();
}
Descargar en Markdown Pensado para pegar en ChatGPT, Claude u otra IA. Incluye solo el contenido de esta pagina.