Firewall (WAF) Conecta tu .htaccess con Cloudflare para bloquear ataques en PrestaShop

Tener una tienda online hoy en día significa convivir con un ruido de fondo insoportable. Si abres los logs de tu servidor ahora mismo, verás miles de peticiones absurdas de bots buscando archivos como wp-login.php o xmlrpc.php. Lo absurdo de la situación es obvio: tu tienda está hecha en PrestaShop, pero los bots te atacan como si fueras un WordPress.

Agotan los recursos de tu hosting intentando forzar puertas que ni siquiera existen en tu infraestructura. Sin embargo, el peligro real no es que te hackeen con esos métodos; el verdadero peligro es la solución que la mayoría de la gente aplica para frenarlos.

Cuando instalas un firewall genérico en tu servidor para detener este tráfico, lo más probable es que termines construyendo un búnker tan estricto que deje fuera a tus propios compradores. En este artículo te explicamos cómo configurar un «Blackhole» inteligente utilizando Cloudflare y tu .htaccess: un sistema automatizado que desvía y traga a los atacantes reales en el borde de la red, garantizando que el camino quede 100% libre para tus clientes.

El problema: Los firewalls tradicionales no están pensados para PrestaShop

La respuesta estándar para proteger una web suele ser meter un firewall masivo en el archivo .htaccess (como las famosas reglas 8G o nG Firewall). En un blog de recetas o en una web corporativa estática, esto funciona de maravilla. En una tienda PrestaShop, es una ruleta rusa financiera.

Esos cortafuegos buscan patrones sospechosos, pero el comportamiento normal de un cliente en una tienda online a menudo se parece al de un bot malicioso a ojos de un filtro rígido. Aplicar estas reglas a ciegas suele romper tres pilares clave de tus ventas:

  • Búsquedas legítimas rotas: Un cliente que busca una marca con apóstrofo (como L’Oréal) o que usa ciertos caracteres en el buscador puede activar una regla de «Inyección SQL» y recibir un pantallazo de error.
  • Campañas de marketing saboteadas: Las plataformas de publicidad (Meta, TikTok, Google Ads) añaden parámetros y cookies de seguimiento enormes a las URLs para medir las conversiones. Muchas reglas de cookies antiguas confunden estos caracteres con ataques y bloquean al usuario justo después de que hayas pagado por su clic.
  • Módulos y pasarelas de pago caídos: Los transportistas, los módulos de sincronización de stock y los callbacks de los bancos suelen usar curl o llamadas automatizadas para comunicarse con tu PrestaShop. Si tu firewall los bloquea, los pedidos no se procesarán bien.

La realidad del sector: En ecommerce, un falso positivo no es un simple error técnico; es una venta perdida, un carrito abandonado y dinero tirado a la basura en publicidad.

El objetivo: Filtrar por nivel de confianza

Para no dañar el negocio, tu estrategia de seguridad no puede ser un muro ciego. Necesitas un sistema que sepa distinguir un comportamiento «extraño pero posiblemente humano» de un «ataque informático flagrante».

La solución pasa por dividir tu criterio de detección en dos niveles muy claros:

  1. Señales de baja confianza (Tráfico dudoso): Un usuario con un navegador raro o una cookie extraña. Acción: Se le muestra un error 403 o se le monitoriza en el log, pero nunca se le banea. El margen de duda siempre favorece al cliente.
  2. Señales de alta confianza (Atacante confirmado): Una IP que intenta acceder a /wp-admin/setup-config.php en una tienda PrestaShop. No hay debate posible: ningún cliente real teclea eso por error. Es un bot de escaneo. Acción: Bloqueo inmediato y envío directo al Blackhole.

Queremos que este segundo grupo sea expulsado de inmediato, pero no en tu servidor (donde ya ha consumido CPU y RAM para procesar la petición), sino en Cloudflare, antes siquiera de que llegue a rozar tu hosting.

La Arquitectura del Sistema

El diseño lógico es sumamente limpio y eficiente:

El .htaccess detecta la petición maliciosa y la reescribe internamente hacia un script PHP recolector. Este script responde con un 403, extrae la IP real del atacante de forma segura y —si la señal es de alta confianza— la empuja a la lista blackhole en Cloudflare. A partir de ese segundo, Cloudflare bloquea al atacante en su red global.

Qué ingredientes necesitas:

  • Un firewall en .htaccess (la base de un 8G te servirá perfectamente).
  • Una cuenta de Cloudflare activa gestionando las DNS de tu dominio.
  • Una IP List creada en tu panel de Cloudflare (el contenedor blackhole para almacenar las IPs).
  • Una Custom Rule (WAF) en Cloudflare que aplique un bloqueo estricto a las IPs de esa lista.
  • Un API Token de Cloudflare con permisos de edición para la lista.
  • Un pequeño script PHP «recolector» en tu servidor.

Implementación Paso a Paso

1. Crear la IP List en Cloudflare

Ve a tu panel de Cloudflare → Manage Account → Configurations → Lists. Crea una nueva lista de tipo IP (puedes llamarla firewall_blackhole). Una vez creada, copia y guarda a buen recaudo su ID (una cadena hexadecimal de 32 caracteres). Nota: No confundas el nombre descriptivo de la lista con su ID interno.

2. Activar la regla en el WAF

Dirígete a Security → WAF → Custom rules y crea una regla con la siguiente lógica:

  • Si: IP Source Address está en la lista firewall_blackhole
  • Entonces (Acción): Block

💡 Recuerda: La lista es el agujero negro, pero esta regla es la gravedad que atrae al bot. Si olvidas este paso, acumularás direcciones IP en una lista que nadie está consultando.

3. El Script Recolector (bad-request.php)

Coloca un archivo PHP en la raíz de tu servidor. Su trabajo debe ser ultrarrápido y seguro:

PHP

<?php
/**
 * bad-request.php - Colector del 8G Firewall + reglas propias -> Cloudflare IP List
 *
 * El .htaccess reescribe internamente aquí las peticiones maliciosas:
 *   /bad-request.php?r=QS|URI|UA|RM|RH|RF|CK|WP|CUSTOM
 */

// ===================== CONFIG =====================
define('CF_API_TOKEN', getenv('CF_API_TOKEN') ?: 'TU_API_TOKEN_AQUI');
const CF_ACCOUNT_ID = 'TU_ACCOUNT_ID_AQUI';
const CF_LIST_ID    = 'TU_LIST_ID_HEX_AQUI'; // ID de tu lista firewall_blackhole

// Solo se BANEA en fuentes de MÁXIMA confianza:
const BAN_ON = ['WP', 'CUSTOM', 'RM'];

// Caducidad del baneo (30 días en segundos)
const BAN_TTL = 2592000; 

// Auto-podado SIN cron (cada 6 horas revisa y limpia IPs caducadas en Cloudflare)
const PRUNE_INTERVAL = 21600; 

define('STATE_DIR',  __DIR__ . '/.bad-request');
define('LOG_FILE',   STATE_DIR . '/blackhole.log');
define('STATE_FILE', STATE_DIR . '/blocked_ips.json');
define('PRUNE_STAMP', STATE_DIR . '/last_prune');
// ==================================================

$source = preg_replace('/[^A-Z]/', '', strtoupper($_GET['r'] ?? 'UNK')) ?: 'UNK';
$ua     = substr($_SERVER['HTTP_USER_AGENT'] ?? '-', 0, 250);
$uri    = substr($_SERVER['REQUEST_URI'] ?? '-', 0, 250);
$ip     = getRealIp();

// Responder 403 y soltar al cliente de inmediato
http_response_code(403);
header('Content-Length: 0');
flush();

if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
} elseif (function_exists('litespeed_finish_request')) {
    litespeed_finish_request();
}

ensureStateDir();
maybePrune();

// Validar que la IP sea pública y real
$validPublic = $ip !== null
    && filter_var($ip, FILTER_VALIDATE_IP)
    && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);

$shouldBan = $validPublic && in_array($source, BAN_ON, true);

if (!$shouldBan) {
    logLine(sprintf('LOG   %s | r:%s | UA:%s | URI:%s', $ip ?? '-', $source, $ua, $uri));
    exit;
}

$blocked = loadState();
if (isset($blocked[$ip])) {
    logLine(sprintf('DUP   %s | r:%s', $ip, $source));
    exit;
}

logLine(sprintf('BAN   %s | r:%s | UA:%s | URI:%s', $ip, $source, $ua, $uri));

if (pushToCloudflare($ip, $source)) {
    $blocked[$ip] = time();
    saveState($blocked);
    logLine(sprintf('CF-OK %s', $ip));
} else {
    logLine(sprintf('CF-FAIL %s (se reintentara en el proximo hit)', $ip));
}
exit;

// ===================== FUNCIONES DE RED Y API =====================

function getRealIp() {
    $remote = $_SERVER['REMOTE_ADDR'] ?? '';
    if ($remote !== '' && isCloudflareIp($remote) && !empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
        return $_SERVER['HTTP_CF_CONNECTING_IP'];
    }
    return $remote !== '' ? $remote : null;
}

function isCloudflareIp($ip) {
    static $ranges = [
        // IPv4
        '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
        '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
        '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
        '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
        // IPv6
        '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
        '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
    ];
    foreach ($ranges as $cidr) {
        if (ipInCidr($ip, $cidr)) return true;
    }
    return false;
}

function ipInCidr($ip, $cidr) {
    if (strpos($cidr, '/') === false) return $ip === $cidr;
    list($subnet, $bits) = explode('/', $cidr);
    $bits = (int) $bits;

    $ipBin     = @inet_pton($ip);
    $subnetBin = @inet_pton($subnet);
    if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) {
        return false;
    }

    $bytes = intdiv($bits, 8);
    $rem   = $bits % 8;

    if ($bytes > 0 && strncmp($ipBin, $subnetBin, $bytes) !== 0) return false;
    if ($rem === 0) return true;
    
    $mask = chr((0xff << (8 - $rem)) & 0xff);
    return (ord($ipBin[$bytes]) & ord($mask)) === (ord($subnetBin[$bytes]) & ord($mask));
}

function cfItemsUrl() {
    return 'https://api.cloudflare.com/client/v4/accounts/' . CF_ACCOUNT_ID . '/rules/lists/' . CF_LIST_ID . '/items';
}

function cfApi($method, $url, $payload = null, $timeout = 5) {
    $opts = [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT        => $timeout,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . CF_API_TOKEN,
            'Content-Type: application/json',
        ],
    ];
    if ($payload !== null) $opts[CURLOPT_POSTFIELDS] = json_encode($payload);
    $ch = curl_init($url);
    curl_setopt_array($ch, $opts);
    $resp = curl_exec($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return ['code' => $code, 'body' => $resp ? json_decode($resp, true) : null];
}

function pushToCloudflare($ip, $source) {
    $r = cfApi('POST', cfItemsUrl(), [[
        'ip'      => $ip,
        'comment' => '8G auto-ban [' . $source . '] ' . gmdate('c'),
    ]]);
    return $r['code'] >= 200 && $r['code'] < 300;
}

function maybePrune() {
    $now  = time();
    $last = is_file(PRUNE_STAMP) ? (int) @file_get_contents(PRUNE_STAMP) : 0;
    if ($now - $last < PRUNE_INTERVAL) return;
    @file_put_contents(PRUNE_STAMP, (string) $now, LOCK_EX);
    pruneCloudflare();
}

function pruneCloudflare() {
    $cutoff   = time() - BAN_TTL;
    $toDelete = [];
    $cursor   = '';

    do {
        $url = cfItemsUrl() . '?per_page=500' . ($cursor !== '' ? '&cursor=' . urlencode($cursor) : '');
        $r   = cfApi('GET', $url, null, 8);
        if ($r['code'] < 200 || $r['code'] >= 300 || empty($r['body']['success'])) return;
        
        foreach (($r['body']['result'] ?? []) as $item) {
            $created = isset($item['created_on']) ? strtotime($item['created_on']) : 0;
            if ($created && $created < $cutoff && !empty($item['id'])) {
                $toDelete[] = ['id' => $item['id']];
            }
        }
        $cursor = $r['body']['result_info']['cursors']['after'] ?? '';
    } while ($cursor !== '');

    if (!$toDelete) return;

    $deleted = 0;
    foreach (array_chunk($toDelete, 1000) as $chunk) {
        $r = cfApi('DELETE', cfItemsUrl(), ['items' => $chunk], 8);
        if ($r['code'] >= 200 && $r['code'] < 300) $deleted += count($chunk);
    }
    logLine(sprintf('PRUNE eliminadas %d IPs caducadas (>%dd)', $deleted, BAN_TTL / 86400));
}

function ensureStateDir() {
    if (!is_dir(STATE_DIR)) @mkdir(STATE_DIR, 0755, true);
    $guard = STATE_DIR . '/.htaccess';
    if (!is_file($guard)) {
        @file_put_contents($guard,
            "# Acceso denegado a logs/estado del colector\n"
            . "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n"
            . "<IfModule !mod_authz_core.c>\nOrder deny,allow\nDeny from all\n</IfModule>\n"
        );
    }
}

function loadState() {
    if (!is_file(STATE_FILE)) return [];
    $data = json_decode(@file_get_contents(STATE_FILE), true);
    return is_array($data) ? $data : [];
}

function saveState($data) {
    $cutoff = time() - BAN_TTL;
    $data = array_filter($data, static function ($t) use ($cutoff) { return $t > $cutoff; });
    @file_put_contents(STATE_FILE, json_encode($data), LOCK_EX);
}

function logLine($msg) {
    @file_put_contents(LOG_FILE, gmdate('Y-m-d H:i:s') . ' | ' . $msg . PHP_EOL, FILE_APPEND | LOCK_EX);
}

Consejo de seguridad: Valida siempre las IPs contra los rangos oficiales de Cloudflare (tanto IPv4 como IPv6). De lo contrario, un atacante sofisticado podría manipular la cabecera CF-Connecting-IP haciendo peticiones directas a tu servidor.

4. Descargar la base del Firewall (nG / 8G) y adaptarla a tu tienda

Ahora que la infraestructura está lista, necesitamos las reglas de detección. Para no inventar la rueda, utilizaremos nG Firewall como nuestra biblioteca base de reglas para Apache. Descárgalo y, antes de subirlo, edítalo con cuidado para comentar (añadiendo un # delante) aquellas directrices propensas a romper tu PrestaShop.

No os voy a mentir, el archivo es enorme. Lo mejor que podéis hacer aquí es apoyaros en una IA como Claude: pasadle el archivo y pedidle que os aconseje qué líneas deshabilitar específicamente para un entorno PrestaShop.

De hecho, la regla que sí o sí vais a tener que desactivar porque es una máquina de generar falsos positivos con las cookies de marketing y de sesión es esta:

# RewriteCond %{HTTP_COOKIE} (<|>|\'|%0A|%0D|%27|%00) [NC]

El gran cambio: Conectar nG Firewall con tu script

Por defecto, todas las secciones de nG Firewall terminan con un portazo seco que bloquea al usuario en el servidor: RewriteRule .* - [F].

Para que nuestro sistema funcione, tenemos que comentar esa línea al final de cada bloque de reglas y sustituirla por la llamada a nuestro recolector. El parámetro ?r= le dirá al script de qué sección viene la amenaza.

Dependiendo de la sección del firewall que estés editando, cambia el final para que apunte a donde corresponda:

RewriteRule .* - [F]

Y añadir estas dónde corresponden, el parámetro es el tipo de petición

RewriteRule .* /bad-request.php?r=CUSTOM [L]
RewriteRule .* /bad-request.php?r=WP [L]
RewriteRule .* /bad-request.php?r=QS [L]
RewriteRule .* /bad-request.php?r=URI [L]
RewriteRule .* /bad-request.php?r=UA [L]
RewriteRule .* /bad-request.php?r=RH [L]
RewriteRule .* /bad-request.php?r=RF [L]
RewriteRule .* /bad-request.php?r=RM [L]

5. Conectar el .htaccess con el recolector

Modifica las reglas de tu firewall en el .htaccess. En lugar de utilizar un bloqueo seco con [F], reescribe la petición de manera interna ([L], sin redirección externa [R]) hacia tu script, indicándole el motivo:

# Evitar bucles: no procesar el propio script recolector
RewriteRule ^bad-request\.php$ - [L]

# Ejemplo: Bloqueo de escáneres de WordPress apuntando al recolector (r=WP)
RewriteCond %{REQUEST_URI} ^/(wp-login\.php|xmlrpc\.php|wp-admin/) [NC]
RewriteRule .* /bad-request.php?r=WP [L]

El uso de la reescritura interna es vital: si hicieras una redirección 302, el bot realizaría una nueva petición limpia y perderías la información original (la URL que atacó o su User-Agent).

6. Reglas personalizadas «Caza-Malware»

Aprovecha este mecanismo para añadir tus propias trampas para URLs que jamás pediría un cliente real. Si detectas en tus logs diarios intentos de acceso a backdoors comunes o paneles de administración antiguos que ya no utilizas, añádelos apuntando a r=CUSTOM:

RewriteCond %{REQUEST_URI} (^|/)webshell\.php$ [NC,OR]
RewriteCond %{REQUEST_URI} ^/viejopanel(/|$) [NC]
RewriteRule .* /bad-request.php?r=CUSTOM [L]

7. Ojo con los blogs en subcarpetas

Si tienes un blog de WordPress conviviendo en una subcarpeta de tu tienda PrestaShop (ej. /blog/), recuerda que este tendrá su propio archivo .htaccess. Por arquitectura de Apache, las reglas del directorio raíz no se heredan automáticamente si el subdirectorio activa su propio RewriteEngine On. Si quieres proteger esa zona, deberás replicar las reglas de seguridad dentro del .htaccess del blog, situándolas fuera de los bloques protegidos por # BEGIN WordPress.

8. Auditoría y Monitorización activa

  • Prueba el sistema: Intenta acceder deliberadamente a [tu-web.com/xmlrpc.php](https://tu-web.com/xmlrpc.php) desde una conexión externa (con el móvil sin estar en la misma wifi). Debes recibir un error 403 y tu IP debería listarse en Cloudflare de inmediato.
  • Vigila los falsos positivos: Revisa el archivo de logs del script durante las primeras semanas. Si ves que alguna IP de cliente legítimo ha sido procesada, audita qué regla la activó y perfecciónala. En seguridad para ecommerce, un log con dudas es aceptable; un cliente bloqueado no lo es.

Conclusión

La seguridad en el comercio electrónico requiere un enfoque pragmático y equilibrado. No se trata de construir un búnker infranqueable si para ello tienes que cerrar las ventanas a tus compradores.

Al delegar el bloqueo pesado en el borde de la red de Cloudflare mediante este sistema de blackhole y afinar el archivo .htaccess de tu PrestaShop para que solo actúe con disparadores de alta confianza, consigues lo mejor de ambos worlds: un servidor que respira aliviado de ataques automatizados y un embudo de ventas impecable donde tus clientes reales se sienten como en casa.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Kebes
Scroll al inicio