Cómo configurar un WAF con HAProxy
Guía completa para añadir protección de firewall de aplicaciones web a HAProxy usando coraza-spoa con el OWASP Core Rule Set, además de defensas nativas con ACL y stick-tables.
HAProxy es el balanceador de carga de código abierto más usado del mundo, pero por sí solo no es un firewall de aplicaciones web. El HAProxy de código abierto no tiene ningún módulo WAF nativo; inspecciona y enruta el tráfico, termina TLS y aplica ACLs y límites de tasa, pero no ejecuta de forma nativa la detección de ataques a nivel de aplicación basada en firmas o reglas.
Para dotar al HAProxy de código abierto de una verdadera capacidad WAF, delegas la inspección de las peticiones en un motor externo mediante el Stream Processing Offload Protocol (SPOP), usando un Stream Processing Offload Agent (SPOA). La opción de código abierto más práctica es coraza-spoa, que ejecuta el motor OWASP Coraza y el OWASP Core Rule Set (CRS) como un SPOA al que HAProxy consulta en cada petición. HAProxy Enterprise ofrece una ruta distinta: un WAF integrado y potenciado con ML que se ejecuta en el mismo proceso, sin un agente aparte que gestionar.
Esta guía te lleva por ambas capas de defensa. Primero refuerzas HAProxy con ACLs y stick-tables nativas para el límite de tasa y el filtrado básico, y después despliegas coraza-spoa con el OWASP CRS para lograr una protección completa a nivel de aplicación. Al terminar tendrás un WAF funcional y ajustable delante de tus backends, sin coste de licencia.
Requisitos Previos
- Se recomienda HAProxy 2.6+ (SPOE está disponible desde la 1.7, pero conviene usar una rama LTS actual)
- Acceso root o sudo a tu servidor
- Ubuntu 22.04+, Debian 12+ o RHEL/Rocky 9+ (otras distribuciones pueden variar)
- Una aplicación backend en funcionamiento ya proxeada a través de HAProxy
- Go 1.25+ y Git instalados (el go.mod de coraza-spoa requiere una cadena de herramientas de Go actual para compilar desde el código fuente)
- Familiaridad básica con la configuración de HAProxy (frontends, backends, ACLs)
Guía Paso a Paso
Elige tu enfoque de WAF para HAProxy
El HAProxy de código abierto no incluye un módulo WAF. Existen tres opciones reales para añadir uno, y conviene elegir con criterio antes de instalar nada:
- coraza-spoa (código abierto, la opción recomendada aquí): Ejecuta el motor OWASP Coraza y el OWASP Core Rule Set como un Stream Processing Offload Agent. HAProxy envía cada petición al agente por SPOP y actúa según el veredicto. Usa las mismas reglas SecLang que ModSecurity.
- SPOA de ModSecurity (código abierto): Un SPOA más antiguo que integra el motor de ModSecurity. Funciona, pero recibe menos mantenimiento activo que Coraza y, en la práctica, queda desplazado por coraza-spoa en los despliegues nuevos.
- WAF de HAProxy Enterprise (comercial): Un WAF integrado y potenciado con ML que se ejecuta en el mismo proceso que el balanceador, con un modo opcional de compatibilidad con el OWASP CRS. No hay agente aparte y la latencia añadida es casi nula, pero requiere una licencia Enterprise.
Sea cual sea el WAF que elijas, las ACLs y stick-tables nativas de HAProxy se encargan del límite de tasa y del filtrado grueso. Esta guía usa coraza-spoa porque es gratuito, de código abierto y ejecuta las mismas reglas del CRS que ModSecurity.
Añade protección básica con ACLs y stick-tables
Antes de añadir un motor WAF, usa las funciones nativas de HAProxy para el límite de tasa y el filtrado básico de peticiones. Las stick-tables registran el estado por cliente; las ACLs te permiten bloquear o limitar en función de ese estado. Añade esto a tu frontend en /etc/haproxy/haproxy.cfg:
frontend web
mode http
bind :80
# Buffer the request body so the WAF can inspect POST and JSON payloads later
option http-buffer-request
# Track request rate per source IP over a 10s window
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
# Deny clients making more than 100 requests per 10 seconds
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
# Basic path-based filtering example
acl block_path path_beg /.git /.env /wp-login.php
http-request deny deny_status 403 if block_path
default_backend web-backend
Recarga y confirma que la configuración es válida:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Instala coraza-spoa
coraza-spoa es un programa en Go que ejecuta el motor WAF Coraza y lo expone a HAProxy mediante SPOP. Compílalo desde el código fuente:
# Clone the SPOA
cd /opt
sudo git clone https://github.com/corazawaf/coraza-spoa.git
cd coraza-spoa
# Build the binary with Mage (there is no Makefile; requires Go 1.25+ per go.mod)
sudo go run mage.go build
# Install the compiled binary onto your PATH (it is written to build/)
sudo cp build/coraza-spoa /usr/local/bin/
Crea un directorio para alojar la configuración del agente y las reglas:
sudo mkdir -p /etc/coraza-spoa/rules
Descarga y configura el OWASP Core Rule Set
Coraza ejecuta las reglas estándar del OWASP CRS, las mismas reglas SecLang que usa ModSecurity. Descarga el CRS y su configuración base:
cd /etc/coraza-spoa/rules
sudo git clone https://github.com/coreruleset/coreruleset.git
cd coreruleset
# Create the CRS setup file from the example
sudo cp crs-setup.conf.example crs-setup.conf
También necesitas una configuración base de Coraza, que establece el modo del motor de reglas y el tratamiento del cuerpo de la petición. Crea /etc/coraza-spoa/rules/coraza.conf con las directivas principales:
# Start in detection mode until you have tuned false positives
SecRuleEngine DetectionOnly
SecRequestBodyAccess On
SecRequestBodyLimit 13107200
SecRequestBodyLimitAction Reject
SecResponseBodyAccess On
SecResponseBodyMimeType text/plain text/html text/xml application/json
SecAuditEngine RelevantOnly
SecAuditLogParts ABIJDEFHZ
SecAuditLog /var/log/coraza-spoa/audit.log
Configura el agente coraza-spoa
El agente necesita su propia configuración que le indique qué reglas cargar y dónde escuchar a HAProxy. Crea /etc/coraza-spoa/config.yaml:
# coraza-spoa agent configuration
bind: 127.0.0.1:9000
log_level: info
# Must match the name of an application defined in the list below
default_application: default
# applications is a YAML list; each entry is an object with a name
applications:
- name: default
directives: |
Include /etc/coraza-spoa/rules/coraza.conf
Include /etc/coraza-spoa/rules/coreruleset/crs-setup.conf
Include /etc/coraza-spoa/rules/coreruleset/rules/*.conf
# Enable response-phase inspection so the coraza-res message and the
# http-response deny rule actually run (defaults to false)
response_check: true
transaction_ttl_ms: 60000
log_level: info
log_file: /var/log/coraza-spoa/coraza.log
Crea el directorio de logs:
sudo mkdir -p /var/log/coraza-spoa
Comprueba que el agente arranca y carga las reglas sin errores:
sudo coraza-spoa -config /etc/coraza-spoa/config.yaml
Configura el filtro SPOE de HAProxy
HAProxy se comunica con el agente a través de un archivo de configuración del Stream Processing Offload Engine (SPOE). Este define qué datos de la petición se envían a Coraza y en qué variables se escribe el veredicto. Crea /etc/haproxy/coraza.cfg:
[coraza]
spoe-agent coraza-agent
messages coraza-res
groups coraza-req
option var-prefix coraza
option set-on-error error
timeout hello 2s
timeout idle 2m
timeout processing 500ms
use-backend coraza-spoa
log global
spoe-message coraza-req
args app=var(txn.coraza.app) src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body exportRuleIDs=bool(false)
spoe-message coraza-res
args app=var(txn.coraza.app) id=var(txn.coraza.id) version=res.ver status=status headers=res.hdrs body=res.body exportRuleIDs=bool(false) detect-only=bool(false)
event on-http-response
spoe-group coraza-req
messages coraza-req
La petición se envía mediante un spoe-group que el frontend dispara con send-spoe-group; el mensaje de respuesta se dispara automáticamente en el evento on-http-response. El orden de los argumentos coincide exactamente con el del ejemplo oficial coraza.cfg, y es obligatorio que estén en ese orden.
Después define un backend que apunte al agente para que HAProxy sepa dónde encontrarlo. Añade esto a /etc/haproxy/haproxy.cfg:
backend coraza-spoa
mode tcp
option spop-check
server coraza 127.0.0.1:9000 check
Integra la decisión del WAF en tu frontend
Ahora asocia el filtro SPOE a tu frontend y actúa según el veredicto que devuelve Coraza. Actualiza el frontend del Paso 2 en /etc/haproxy/haproxy.cfg:
frontend web
mode http
bind :80
# Buffer the request body so Coraza can inspect POST and JSON payloads
option http-buffer-request
# Native rate limiting (from Step 2)
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
# Select which coraza-spoa application this frontend uses
http-request set-var(txn.coraza.app) str(default)
# Attach the Coraza WAF filter and send the request to the agent
filter spoe engine coraza config /etc/haproxy/coraza.cfg
http-request send-spoe-group coraza coraza-req
# Deny requests and responses Coraza flags as attacks
http-request deny deny_status 403 if { var(txn.coraza.action) -m str deny }
http-response deny deny_status 403 if { var(txn.coraza.action) -m str deny }
default_backend web-backend
La línea option http-buffer-request no es opcional si te preocupan los ataques en el cuerpo POST. Sin ella, HAProxy no rellena de forma fiable la muestra req.body antes de enviar la petición al agente, por lo que las cargas de SQLi o XSS transportadas en cuerpos de petición de formularios o JSON nunca se envían a Coraza y quedan sin inspeccionar, aunque SecRequestBodyAccess esté en On. La inspección del cuerpo de la petición está limitada por tune.bufsize (16 KB por defecto): HAProxy solo puede almacenar en búfer alrededor de un bufsize, así que los cuerpos mayores que eso no se envían por completo a Coraza. Si necesitas inspeccionar cuerpos de formulario o JSON más grandes, aumenta tune.bufsize con moderación (por ejemplo a 64 KB) en lugar de igualarlo a un SecRequestBodyLimit de varios megabytes, lo que multiplicaría la memoria por conexión y arriesgaría una condición de falta de memoria.
Valida la configuración completa antes de recargar:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
Ejecuta coraza-spoa como servicio
Ejecuta el agente bajo systemd para que arranque en el inicio del sistema y se reinicie ante un fallo. Crea /etc/systemd/system/coraza-spoa.service:
[Unit]
Description=Coraza SPOA WAF agent for HAProxy
After=network.target
[Service]
ExecStart=/usr/local/bin/coraza-spoa -config /etc/coraza-spoa/config.yaml
Restart=on-failure
User=haproxy
Group=haproxy
[Install]
WantedBy=multi-user.target
Habilita y arranca el agente, y después recarga HAProxy:
sudo systemctl daemon-reload
sudo systemctl enable --now coraza-spoa
sudo systemctl reload haproxy
# Confirm the agent is running and listening
sudo systemctl status coraza-spoa
Prueba el WAF
Establece temporalmente SecRuleEngine On en /etc/coraza-spoa/rules/coraza.conf, reinicia el agente y envía cargas de ataque conocidas. Deberían bloquearse con un 403:
sudo systemctl restart coraza-spoa
# SQL injection attempt (should be blocked)
curl -I 'http://your-server.com/?id=1%20OR%201=1'
# XSS attempt (should be blocked)
curl -I 'http://your-server.com/?q=<script>alert(1)</script>'
# Request-body SQL injection attempt (should be blocked with http-buffer-request enabled)
curl -I -X POST -d 'id=1 OR 1=1' 'http://your-server.com/login'
# A normal request (should pass with 200)
curl -I 'http://your-server.com/'
Confirma que los bloqueos aparecen en el log de auditoría de Coraza:
sudo tail -f /var/log/coraza-spoa/audit.log
Deberías ver entradas con los IDs de las reglas del CRS coincidentes y la puntuación de anomalía que activó la denegación.
Ajusta los falsos positivos
Ejecuta en modo DetectionOnly durante una o dos semanas y revisa el log de auditoría en busca de peticiones legítimas que se habrían bloqueado. Ajusta usando los mecanismos estándar del CRS en un archivo personalizado que se cargue después de las reglas, por ejemplo /etc/coraza-spoa/rules/custom-exclusions.conf:
# Disable a specific rule that misfires
SecRuleRemoveById 942100
# Disable a rule only for a specific path
SecRule REQUEST_URI "@beginsWith /api/upload" \
"id:1000,phase:1,pass,nolog,ctl:ruleRemoveById=920420"
También puedes bajar el nivel de paranoia del CRS en crs-setup.conf para reducir los falsos positivos (1 = menos falsos positivos, 4 = más estricto):
SecAction "id:900000,phase:1,pass,t:none,nolog,\
setvar:tx.blocking_paranoia_level=1"
Una vez que el log esté limpio, establece SecRuleEngine On y reinicia el agente para empezar a aplicar el bloqueo.
Conclusión y Próximos Pasos
Tu instancia de HAProxy cuenta ahora con protección por capas: ACLs y stick-tables nativas para el límite de tasa y el filtrado grueso, más un WAF completo a nivel de aplicación mediante coraza-spoa ejecutando el OWASP Core Rule Set. Esto le da al HAProxy de código abierto la capacidad WAF que le falta de forma nativa, sin coste de licencia.
Próximos pasos:
- Monitoriza
/var/log/coraza-spoa/audit.logen busca de ataques bloqueados y falsos positivos - Tras una o dos semanas en modo DetectionOnly, cambia SecRuleEngine a On
- Configura la rotación de logs para los logs de auditoría y del agente
- Envía los logs de auditoría de Coraza a tu SIEM (ELK, Splunk, Grafana) para correlacionarlos
- Programa actualizaciones periódicas del OWASP CRS y vuelve a probar tras cada actualización
- Si el rendimiento en el mismo proceso y la detección basada en ML te importan más que la transparencia de las reglas, evalúa el WAF integrado de HAProxy Enterprise como alternativa al enfoque SPOA
Solución de Problemas
HAProxy se recarga pero el WAF nunca bloquea nada
Comprueba que el agente se está ejecutando (systemctl status coraza-spoa) y que SecRuleEngine está en On y no en DetectionOnly. En modo DetectionOnly, Coraza registra las coincidencias pero nunca establece el veredicto de denegación, así que HAProxy no tiene nada sobre lo que actuar.
Los ataques por cadena de consulta se bloquean, pero los del cuerpo POST no
Esto significa que los cuerpos de las peticiones no están llegando a Coraza. Añade option http-buffer-request al frontend para que HAProxy almacene en búfer y rellene req.body antes de enviar la petición al agente. Sin ello, SecRequestBodyAccess On no tiene datos que inspeccionar. Ten en cuenta que el tamaño del cuerpo almacenado en búfer está limitado por tune.bufsize (16 KB por defecto); auméntalo con moderación si necesitas inspeccionar cuerpos más grandes.
La variable txn.coraza.action nunca se establece
El var-prefix de /etc/haproxy/coraza.cfg debe coincidir con el nombre de la variable en tu ACL http-request deny. Si var-prefix es coraza, la variable es txn.coraza.action. Un desajuste hace que la ACL nunca coincida de forma silenciosa.
Todo el tráfico se deniega tras habilitar el bloqueo
Normalmente es una avalancha de falsos positivos de un CRS sin ajustar con un nivel de paranoia alto. Vuelve a poner SecRuleEngine en DetectionOnly, baja el nivel de paranoia a 1 y revisa el log de auditoría para localizar los IDs de las reglas problemáticas antes de volver a habilitarlo.
HAProxy deja pasar el tráfico cuando el agente está caído (fail-open)
Con option set-on-error error, HAProxy deja pasar el tráfico si no puede alcanzar el agente. Esto evita que una caída del agente deje tu sitio fuera de servicio, pero también implica que una caída sin monitorizar desactiva la protección. Configura alertas sobre la salud del servicio coraza-spoa y sobre los contadores de errores SPOE en las estadísticas de HAProxy.
El agente no arranca por un error de análisis de reglas
Lee la salida de arranque; indica el archivo y la línea de la primera regla que no se pudo interpretar. Las causas habituales son una ruta de Include ausente, un checkout del CRS incompleto o una versión de coraza-spoa cuyas claves de configuración difieren de las de esta guía.
Latencia alta tras habilitar el WAF
Cada petición hace ahora un ida y vuelta al agente. Mantén coraza-spoa en el mismo host que HAProxy (127.0.0.1), ajusta más el timeout de processing y considera bajar SecRequestBodyLimit o reducir la inspección del cuerpo de la respuesta si no la necesitas.
Preguntas Frecuentes
¿El HAProxy de código abierto incluye un WAF integrado?
No. El HAProxy de código abierto es un balanceador de carga y proxy inverso; no tiene ningún módulo WAF nativo. Ofrece filtrado basado en ACLs y límite de tasa con stick-tables, pero no detección de ataques a nivel de aplicación. Para obtener un WAF, o bien delegas la inspección en un motor externo mediante SPOP (por ejemplo, coraza-spoa), o bien usas el comercial HAProxy Enterprise, que tiene un WAF integrado.
¿Qué es un SPOA y por qué HAProxy lo necesita para tener un WAF?
SPOA significa Stream Processing Offload Agent. Es un programa externo con el que HAProxy se comunica mediante el Stream Processing Offload Protocol (SPOP) para delegar trabajo que no puede hacer por sí mismo. Como el HAProxy de código abierto no tiene un motor WAF, un SPOA como coraza-spoa ejecuta el WAF en un proceso aparte; HAProxy envía cada petición al agente para su inspección y aplica el veredicto que devuelve.
¿Puedo usar el OWASP Core Rule Set con HAProxy?
Sí. coraza-spoa ejecuta el motor OWASP Coraza, que es totalmente compatible con el lenguaje de reglas SecLang de ModSecurity y con el OWASP CRS. Tus reglas del CRS y cualquier regla SecLang personalizada funcionan igual que lo harían bajo ModSecurity. HAProxy Enterprise también ofrece un modo opcional de compatibilidad con el CRS que ejecuta las reglas del CRS a través de su motor potenciado con ML.
¿Debería usar coraza-spoa o el SPOA de ModSecurity?
Para los despliegues nuevos, coraza-spoa es la mejor opción. Ejecuta el motor OWASP Coraza, en desarrollo activo, en Go puro, usa las mismas reglas del CRS y es más fácil de compilar y contenerizar. El SPOA de ModSecurity, más antiguo, integra el motor de ModSecurity y, en la práctica, ha quedado desplazado. Ten en cuenta que coraza-spoa sigue en estado preview, así que valida los formatos de configuración con la versión que instales.
¿Cómo hago límite de tasa en HAProxy sin un WAF?
Usa stick-tables y ACLs, que son funciones nativas de HAProxy. Define una stick-table que registre una métrica como http_req_rate por IP de origen, rastrea las conexiones con http-request track-sc0 src y después deniega las peticiones que superen un umbral. Esto se ejecuta en el mismo proceso, sin agente externo, y es tu primera capa de defensa, complementando la inspección más profunda que aporta un WAF.
¿El WAF añade latencia a HAProxy?
El enfoque SPOA añade un ida y vuelta de HAProxy al agente en cada petición, por lo que hay una sobrecarga medible frente al procesamiento nativo. Mantén coraza-spoa en el mismo host (127.0.0.1) y ajusta el timeout de processing para minimizarla. El WAF integrado de HAProxy Enterprise evita esto al ejecutarse en el mismo proceso, que es su principal ventaja de rendimiento frente a la ruta SPOA de código abierto.
¿Qué ocurre si el agente coraza-spoa se cae?
Depende de tu gestión de errores de SPOE. Con option set-on-error error, HAProxy falla en modo abierto y deja pasar el tráfico cuando el agente no está accesible, lo que mantiene tu sitio en servicio pero desactiva la protección WAF durante la caída. Por eso, monitoriza el servicio coraza-spoa y los contadores de errores SPOE de HAProxy, y configura alertas sobre la salud del agente para que un fallo silencioso no te deje desprotegido.