Ir al contenido

Asegurar tu instancia Listmonk: validación de endpoints con Traefik

·3 mins
Rogelio Guerra Riverón
Autor
Rogelio Guerra Riverón
Construyendo mi propia infraestructura web desde cero. Aquí documento cada paso: servidores, redes, contenedores y lo que vaya surgiendo.

El problema
#

Tengo Listmonk corriendo en Docker detrás de Traefik para gestionar la newsletter del blog. Alojar tu propia plataforma de email marketing da control, pero también responsabilidad: Listmonk expone por defecto varios endpoints públicos pensados para la suscripción vía formulario embebido, y no todos deberían estar accesibles sin más.

Dos me preocupaban en concreto:

  • /subscription/form — pensado para incrustar un formulario de suscripción en una web externa
  • /api/public/* — API pública sin autenticación, usada por ese mismo formulario

Ninguno de los dos lo uso (la suscripción se gestiona desde el propio blog), así que quedaban ahí como superficie de ataque sin motivo: abuso de API, spam, scraping de listas.

Lo que sí necesitaba mantener intacto: /subscription/optin, el endpoint que confirma la suscripción cuando un usuario hace clic en el email de doble opt-in. Bloquear por error ese endpoint significa que nadie puede confirmarse nunca en la lista.

La solución: un router de Traefik con prioridad
#

Listmonk ya tenía su router normal en el docker-compose.yml:

labels:
  - traefik.enable=true
  - traefik.http.routers.listmonk.rule=Host(`lista.serviciosrogeliowar.com`)
  - traefik.http.routers.listmonk.entrypoints=websecure
  - traefik.http.routers.listmonk.tls.certresolver=letsencrypt
  - traefik.http.routers.listmonk.middlewares=securityHeaders@file
  - traefik.http.services.listmonk.loadbalancer.server.port=9000

En vez de tocar la app o el config.toml de Listmonk, añadí un segundo router con más prioridad que intercepta solo las rutas problemáticas antes de que lleguen al primero:

  - traefik.http.routers.listmonk-block-sub.rule=Host(`lista.serviciosrogeliowar.com`) && (PathPrefix(`/api/public/`) || Path(`/subscription/form`))
  - traefik.http.routers.listmonk-block-sub.entrypoints=websecure
  - traefik.http.routers.listmonk-block-sub.tls.certresolver=letsencrypt
  - traefik.http.routers.listmonk-block-sub.middlewares=blockListmonkPublicSub@file
  - traefik.http.routers.listmonk-block-sub.priority=100
  - traefik.http.routers.listmonk-block-sub.service=listmonk

La regla rule combina PathPrefix para toda la API pública con Path exacto para el formulario. Path(\/subscription/optin`)` no aparece por ningún lado, así que ese endpoint sigue cayendo en el router normal sin middleware de bloqueo.

El middleware
#

El middleware que intercepta esas rutas no devuelve un simple 403 — redirige al blog, para que un enlace roto no delate que ahí detrás hay una instancia de Listmonk mal configurada:

http:
  middlewares:
    blockListmonkPublicSub:
      redirectRegex:
        regex: ".*"
        replacement: "https://blog.serviciosrogeliowar.com"
        permanent: false

permanent: false es deliberado: es un 302, no un 301. Si en el futuro decido exponer alguno de estos endpoints, no quiero que los navegadores ni los crawlers hayan cacheado permanentemente la redirección al blog.

Por qué la prioridad importa
#

Traefik evalúa routers por prioridad cuando dos reglas podrían matchear la misma petición. Sin priority=100 en el router de bloqueo, Traefik podría enrutar según el orden de descubrimiento de los labels en vez de por especificidad de la regla, y una petición a /api/public/subscribe acabaría cayendo en el router normal de Listmonk en lugar del de bloqueo. Con la prioridad explícita, no hay ambigüedad: el router más específico gana siempre.

Verificación
#

Con el stack levantado, tres pruebas simples confirman que el bloqueo funciona sin romper nada:

curl -I https://lista.serviciosrogeliowar.com/api/public/lists
# → 302 a https://blog.serviciosrogeliowar.com

curl -I https://lista.serviciosrogeliowar.com/subscription/form
# → 302 a https://blog.serviciosrogeliowar.com

curl -I "https://lista.serviciosrogeliowar.com/subscription/optin?..."
# → 200, sigue funcionando

El panel de administración (/admin) sigue detrás del router normal con securityHeaders, sin verse afectado por el router de bloqueo porque su ruta no matchea ninguna de las dos condiciones.

Resultado
#

Los endpoints públicos que no necesitaba quedaron cerrados sin tocar una línea del código de Listmonk ni de su configuración interna — toda la lógica vive en las labels del docker-compose.yml y en un middleware de Traefik. Si mañana cambio de plataforma de newsletter, esta misma técnica de router con prioridad sirve para blindar cualquier endpoint que la app no permita desactivar por su cuenta.