En este tutorial vamos a ver cómo proteger una aplicación Laravel con Livewire de los bots que recorren internet buscando componentes vulnerables. Prepararemos los componentes para que rechacen los valores que ningún formulario puede enviar, contestaremos a las peticiones manipuladas con wirefence y bloquearemos durante un día las direcciones que las envían.

Hace unos días, revisando el log de una de mis webs, encontré dos errores de Livewire registrados en el mismo segundo y a la una de la madrugada: uno en la portada y otro en la página de contacto. Los había provocado un bot que intentaba subir un archivo a dos componentes que no aceptan ninguno. Al protegerme de él descubrí además que Laravel se creía la dirección IP que le enviaba cualquier visitante, así que también veremos cómo comprobar eso en tu servidor.

Introducción

Antes de continuar, te vendrá bien tener a mano estas guías:

Necesitarás Laravel 12 o 13, PHP 8.2 o posterior y Livewire 3.6.4 o posterior, aunque Livewire 4 también sirve. Los ejemplos de este tutorial usan Laravel 13 y Livewire 4.

Revisa qué envían los bots

Todos los componentes de Livewire hablan con el servidor a través de una misma dirección, /livewire/update. En cada interacción el navegador envía tres cosas: una instantánea del estado del componente, los cambios en sus propiedades y los métodos que quiere ejecutar. La instantánea va firmada, de modo que si alguien la modifica Livewire la rechaza. Los cambios de las propiedades y las llamadas a métodos, por el contrario, se pueden escribir a mano.

Eso es justo lo que hacen los bots: copian una instantánea válida de cualquier página pública y se inventan el resto. Una de estas peticiones tiene este aspecto, con la instantánea recortada:

{
    "components": [{
        "snapshot": "{\"data\":{\"name\":\"\"},\"memo\":{\"name\":\"contact-form\"},\"checksum\":\"...\"}",
        "updates": { "name": ["no", "es", "texto"] },
        "calls": [{
            "path": "",
            "method": "_startUpload",
            "params": ["photo", [{ "name": "shell.php", "size": 120, "type": "image/png" }], false]
        }]
    }]
}

Lo bueno es que son predecibles: prueban casi siempre las mismas puertas, como los guardias de Metal Gear con su ronda aprendida. Estas son las más habituales:

  • Un array donde el formulario manda texto: Livewire intenta asignarlo a una propiedad de tipo string y PHP lanza un TypeError.
  • Una propiedad bloqueada: intentan cambiar un identificador que el navegador nunca debería tocar.
  • Propiedades o métodos que no existen: prueban nombres como isAdmin o deleteEverything por si cuelan.
  • Subidas de archivos: piden subir un archivo a un componente que no utiliza WithFileUploads.

Con todo esto buscan aplicaciones con versiones de Livewire anteriores a la 3.6.4, afectadas por la vulnerabilidad CVE-2025-54068, que permitía ejecutar código en el servidor. En una aplicación actualizada no consiguen nada, pero cada intento deja en el log un error 500 con su traza completa. Estos son los dos que encontré yo:

[2026-11-07 01:22:20] production.ERROR: Cannot handle file upload without [Livewire\WithFileUploads] trait on the [pages.home] component class.
[2026-11-07 01:22:20] production.ERROR: Cannot handle file upload without [Livewire\WithFileUploads] trait on the [pages.contact] component class.

Cuando se juntan cientos de líneas así, los errores reales de tu aplicación quedan enterrados debajo.

Actualiza Livewire

Lo primero es asegurarnos de que la vulnerabilidad que buscan no existe en nuestra aplicación. Comprueba la versión de Livewire que tienes instalada:

composer show livewire/livewire

Si la versión es anterior a la 3.6.4, actualízala antes de seguir:

composer update livewire/livewire

Protege las propiedades del componente

Cada propiedad pública de un componente se puede modificar desde el navegador, y cada método público se puede llamar desde él. Conviene tenerlo presente al escribir los componentes, ya que Livewire nos da varias herramientas para limitar lo que puede llegar desde fuera.

Como ejemplo usaremos un componente que permite a un usuario cambiar su nombre y sus temas de interés. Crea la clase con Artisan:

php artisan make:livewire EditProfile --class

En app/Livewire/EditProfile.php guardaremos el identificador del usuario, su nombre y sus intereses:

<?php

namespace App\Livewire;

use App\Models\User;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;

class EditProfile extends Component
{
    #[Locked]
    public int $userId;

    public string $name = '';

    public array $interests = [];

    public function mount(User $user): void
    {
        $this->userId = $user->id;
        $this->name = $user->name;
    }

    public function save(): void
    {
        $user = User::findOrFail($this->userId);

        abort_unless($user->is(auth()->user()), 403);

        $data = $this->validate([
            'name' => ['required', 'string', 'max:100'],
            'interests' => ['array'],
            'interests.*' => ['in:laravel,livewire,php'],
        ]);

        $user->update(['name' => $data['name']]);
    }

    public function render(): View
    {
        return view('livewire.edit-profile');
    }
}

El atributo #[Locked] impide que el navegador cambie userId. Sin él, bastaría con enviar otro número para intentar editar el perfil de otra persona.

Los tipos de las propiedades también ayudan. Como name es un string, Livewire no puede guardar en ella un array, mientras que interests es un array porque la rellenará un grupo de casillas. Ten en cuenta que el tipo debe coincidir con lo que envía el formulario: una propiedad string enlazada a un <select multiple> es un error del componente, y en el siguiente apartado veremos que wirefence la tomaría por una sonda.

Por último, save() comprueba quién hace la petición y valida los datos, aunque la página solo muestre el formulario al propio usuario. Las acciones de Livewire se pueden llamar directamente, así que cada una debe autorizar y validar lo que hace. Tienes más recomendaciones en la guía de seguridad de Livewire.

La vista del componente, en resources/views/livewire/edit-profile.blade.php, enlaza los campos con sus propiedades:

<form wire:submit="save">
    <input wire:model="name">

    <label><input type="checkbox" wire:model="interests" value="laravel"> Laravel</label>
    <label><input type="checkbox" wire:model="interests" value="livewire"> Livewire</label>

    <button>Guardar</button>
</form>

Instala wirefence

Con los componentes bien escritos, los bots ya no consiguen nada, pero siguen llenando el log de errores y volviendo cada noche. Para eso creé wirefence, un paquete que revisa cada petición de Livewire antes de que se asigne ningún valor o se ejecute ningún método. Cuando detecta una petición manipulada, responde con un error 400 en lugar del 500 y, si la dirección insiste, la bloquea durante 24 horas en toda la aplicación.

Instálalo con Composer y ejecuta su migración, que crea la tabla donde guarda los bloqueos:

composer require edulazaro/wirefence:^1.1
php artisan migrate

Laravel descubre el paquete automáticamente, así que no hay que registrar nada. Para saber si una dirección está bloqueada consulta la caché, de modo que a un visitante normal le cuesta una sola lectura en cada petición.

Comprueba qué detecta wirefence

wirefence reconoce las mismas puertas que prueban los bots. A cada una de ellas la llama señal:

Señal Qué ha llegado
type_mismatch un array u objeto para una propiedad de tipo string, int, float o bool
locked_property un cambio en una propiedad con #[Locked]
unknown_property un cambio en una propiedad que no existe o no es pública
unknown_method una llamada a un método que no existe o no es público
upload_probe una subida de archivo a un componente sin WithFileUploads

Una página bien hecha nunca envía un tipo equivocado, ni cambia una propiedad bloqueada, ni sube un archivo a un componente que no los acepta, así que esas tres señales bloquean la dirección al primer intento. Las propiedades y los métodos desconocidos permiten tres intentos en diez minutos por un motivo práctico: si despliegas una versión que renombra un método, una pestaña que alguien dejó abierta puede enviar el nombre antiguo una o dos veces.

Cada petición rechazada deja una sola línea de aviso en el log, con la dirección, la señal y el componente. Cuando una dirección alcanza el límite, se añade la línea del bloqueo:

production.WARNING: wirefence: tampered Livewire request. {"ip":"203.0.113.7","signal":"upload_probe","component":"pages.contact","target":"_startUpload","count":1,"threshold":1}
production.WARNING: wirefence: address blocked. {"ip":"203.0.113.7","signal":"upload_probe","until":"2026-11-15T17:02:02+00:00","strikes":1}

A partir de ese momento, cualquier petición de esa dirección recibe un error 403 antes de llegar a las sesiones, a la base de datos o a Livewire. Las direcciones privadas nunca se bloquean, ya que ver una significa que la aplicación no está recibiendo la dirección real de sus visitantes y bloquearla dejaría fuera a todos.

Configura wirefence

La configuración por defecto sirve para la mayoría de aplicaciones. Si quieres ajustar algo, publica el archivo de configuración:

php artisan vendor:publish --tag=wirefence-config

En config/wirefence.php tienes los intentos de cada señal, la duración del bloqueo y la lista de direcciones que nunca se bloquean. Si ninguno de tus componentes acepta archivos, como en mis webs, indícalo así:

'uploads' => false,

Con esta opción, cualquier intento de subir un archivo recibe un error 404, como si Livewire no admitiese subidas, y la dirección queda bloqueada al primer intento. Las dos rutas de subida y vista previa de Livewire también responden 404. Esta opción vive en el archivo de configuración, junto al código, porque depende de cómo está hecha la aplicación. El .env queda para lo que cambia de un servidor a otro.

Si tienes direcciones fijas que no quieres bloquear nunca, como la de tu oficina, añádelas a allow. Admite direcciones sueltas y rangos:

'allow' => ['198.51.100.20', '192.0.2.0/24'],

Asegúrate de que Laravel ve la IP real

wirefence bloquea la dirección que le da Laravel con $request->ip(), la misma que usan los rate limiters. Si tu aplicación está detrás de Cloudflare, de nginx o de un balanceador, esa dirección depende de los proxies de confianza que hayas configurado, y aquí es donde me llevé el susto.

En mi servidor, nginx ya recuperaba la dirección real del visitante a partir de la cabecera de Cloudflare, mientras que Laravel tenía configurado '*' como proxy de confianza desde hacía tiempo. Con esa combinación, Laravel aceptaba la cabecera X-Forwarded-For que escribía el propio visitante. Cualquiera podía elegir su dirección para saltarse los límites de peticiones y, con wirefence instalado, conseguir que bloqueara la dirección de otra persona.

La configuración correcta depende de lo que haga tu servidor:

  • El servidor ya recupera la IP real: es el caso de nginx con real_ip_header, de Apache con mod_remoteip o de mod_cloudflare en Apache. Laravel no debe confiar en ningún proxy. Es su comportamiento por defecto, así que basta con no llamar a trustProxies().
  • Laravel recibe las direcciones de Cloudflare: confía únicamente en los rangos de Cloudflare, nunca en '*'.

En el segundo caso, la configuración va en bootstrap/app.php. Copia los rangos actuales de la página de Cloudflare, ya que cambian de vez en cuando:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: [
        '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',
        '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
        '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
    ]);
})

Si no puedes cambiar esta configuración, wirefence tiene su propia opción para Cloudflare. Con WIREFENCE_CLOUDFLARE=true, toma la dirección de la cabecera CF-Connecting-IP cuando la petición llega desde un rango de Cloudflare. Solo afecta a wirefence, así que es preferible corregir Laravel y que los rate limiters vean también la dirección correcta.

Comprueba si la IP se puede falsificar

La forma más sencilla de comprobarlo es usar un rate limiter, que cuenta las peticiones por dirección y devuelve en cada respuesta cuántas quedan. Si las peticiones con direcciones inventadas comparten el contador de tu dirección real, Laravel no se deja engañar. Si cada dirección inventada empieza un contador nuevo, se cree lo que le envía el visitante.

Añade una ruta temporal con un límite en routes/web.php:

Route::get('/ping', fn () => 'pong')->middleware('throttle:20,1');

Ahora envía cuatro peticiones desde tu ordenador: una normal, dos con direcciones inventadas en X-Forwarded-For y otra normal. Cambia el dominio por el tuyo:

for ip in "" 203.0.113.77 203.0.113.78 ""; do
  curl -s -D - -o /dev/null ${ip:+-H "X-Forwarded-For: $ip"} https://tu-dominio.com/ping | grep -i x-ratelimit-remaining
done

Si todo está bien, las cuatro peticiones restan del mismo contador:

x-ratelimit-remaining: 19
x-ratelimit-remaining: 18
x-ratelimit-remaining: 17
x-ratelimit-remaining: 16

Esto es lo que obtuve yo antes de corregir la configuración. Cada dirección inventada empieza con 19 peticiones disponibles, y solo la última, de nuevo sin cabecera, sigue el contador real:

x-ratelimit-remaining: 19
x-ratelimit-remaining: 19
x-ratelimit-remaining: 19
x-ratelimit-remaining: 18

Al terminar, elimina la ruta de prueba. La misma confianza en los proxies decide también el esquema y el dominio de las URL que genera Laravel, por lo que con '*' un visitante podría conseguir además que generase enlaces con http://.

Prueba la protección con tests

Para comprobar que wirefence protege el componente, enviaremos una petición manipulada al endpoint real de Livewire, igual que haría un bot. Crea la clase del test:

php artisan make:test WirefenceTest --phpunit

La instantánea la obtenemos con Livewire::test(), que la genera con su firma correcta, y solo inventamos el cambio de la propiedad. Usamos una dirección del rango de documentación 203.0.113.0/24 porque la dirección por defecto de los tests es privada y wirefence nunca la bloquearía. Coloca este contenido en tests/Feature/WirefenceTest.php:

<?php

namespace Tests\Feature;

use App\Livewire\EditProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;

class WirefenceTest extends TestCase
{
    use RefreshDatabase;

    private const BOT = '203.0.113.7';

    public function test_a_forged_update_is_refused_and_the_address_blocked(): void
    {
        $user = User::factory()->create();
        $snapshot = Livewire::actingAs($user)->test(EditProfile::class, ['user' => $user])->snapshot;

        $this->actingAs($user)
            ->withServerVariables(['REMOTE_ADDR' => self::BOT])
            ->withHeaders(['X-Livewire' => 'true'])
            ->postJson(app('livewire')->getUpdateUri(), [
                'components' => [[
                    'snapshot' => json_encode($snapshot),
                    'updates' => ['name' => ['no', 'es', 'texto']],
                    'calls' => [],
                ]],
            ])
            ->assertStatus(400);

        $this->withServerVariables(['REMOTE_ADDR' => self::BOT])->get('/')->assertForbidden();
        $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.8'])->get('/')->assertOk();
    }

    public function test_a_real_save_goes_through(): void
    {
        $user = User::factory()->create();

        Livewire::actingAs($user)
            ->test(EditProfile::class, ['user' => $user])
            ->set('name', 'Edu')
            ->set('interests', ['laravel', 'livewire'])
            ->call('save')
            ->assertHasNoErrors();

        $this->assertSame('Edu', $user->fresh()->name);
    }
}

El primer test envía un array a la propiedad name y comprueba tres cosas: que la petición recibe un 400, que esa dirección ya no puede abrir la portada y que otra dirección sigue entrando sin problemas. El segundo confirma que un guardado normal, con un array en interests, funciona como siempre. Ejecútalos con este comando:

php artisan test --filter=WirefenceTest

Recibe un aviso cuando bloquee una dirección

Cada vez que bloquea una dirección, wirefence lanza el evento ClientBlocked. Podemos escucharlo para recibir un correo con los datos del bloqueo. Añade el listener al método boot() de app/Providers/AppServiceProvider.php:

use EduLazaro\Wirefence\Events\ClientBlocked;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;

public function boot(): void
{
    Event::listen(function (ClientBlocked $event) {
        Mail::raw(
            "wirefence ha bloqueado {$event->ip} hasta {$event->until->toDateTimeString()}.\n"
            ."Señal: {$event->signal->value}. Componente: {$event->component}. Objetivo: {$event->target}.",
            fn ($message) => $message->to('admin@example.com')->subject("IP bloqueada: {$event->ip}"),
        );
    });
}

El evento incluye la dirección, la señal, el componente, la propiedad o el método que se intentó usar, la fecha de fin del bloqueo y el número de veces que se ha bloqueado esa dirección. Si recibes muchos bloqueos, puedes cambiar el correo por un mensaje a Slack o a Telegram.

Desbloquea una dirección

Si bloqueas a alguien por error, o te bloqueas a ti mismo mientras haces pruebas, wirefence incluye tres comandos para gestionar los bloqueos:

php artisan wirefence:list
php artisan wirefence:unblock 203.0.113.7
php artisan wirefence:prune

El primero muestra las direcciones bloqueadas en este momento, y con --all también el historial. El segundo levanta un bloqueo al instante. El tercero borra los registros cuyo bloqueo terminó hace más de 30 días, ya que una dirección IP es un dato personal. Este último se ejecuta también cada día por sí solo, siempre que tengas el programador de tareas de Laravel en el cron del servidor.

Si necesitas apagar wirefence por completo, añade WIREFENCE_ENABLED=false al .env y Livewire se comportará como si el paquete no estuviese instalado.

Finalizando

Ya tienes una aplicación de Livewire que rechaza las peticiones manipuladas sin llenar el log, que bloquea a quien las envía y que conoce la dirección real de sus visitantes. Puedes completar la protección con estas guías:

Puedes consultar la documentación de Wirefence y su código en GitHub.

Esto ha sido todo. ¡Espero que te resulte útil!