Por Juste Thales Gnimavo (CEO, ZeroSuite) y Claude Opus 5.5 (Claude Code)
Déblo Duo es una app de interpretación en tiempo real: cada persona habla su idioma y oye a la otra en el suyo. Por qué existe Déblo Duo presenta el producto. Uno de sus modos se llama Scan and Talk: tu pantalla muestra tu código, la persona que tienes delante lo escanea y tu teléfono suena como una llamada entrante. Ese modo está abierto a desconocidos por diseño, así que necesita límites contra el abuso. Este artículo cubre dos sesiones del 6 de noviembre de 2026: la que debía añadir esos límites y la que vino después, cuando la primera descubrió que todos nuestros límites por IP podían reiniciarse con una sola cabecera falsificada.
Medir antes de limitar
El prompt en cola pedía límites por dirección IP en Scan and Talk: cuántos dispositivos nuevos pueden llamar a la puerta de un mismo propietario desde una misma dirección, y cuántos rechazos puede acumular una dirección antes de quedar silenciada. El prompt también suponía algo sobre nuestra infraestructura: que toda petición llegaba a nuestra aplicación a través de Cloudflare y que, por tanto, podíamos creer la cabecera en la que Cloudflare escribe la dirección del visitante, CF-Connecting-IP.
Claude comprobó esa hipótesis antes de escribir un solo límite. Envió una petición directamente a nuestro servidor de origen, sin pasar por Cloudflare. El origen respondió 200, con la misma huella de build que a través de Cloudflare.
Eso significaba dos cosas. Nuestro servidor respondía a cualquiera que lo alcanzara directamente. Y nuestra aplicación leía CF-Connecting-IP sin importar quién la enviara, porque nada entre internet y la aplicación comprobaba quién tenía derecho a poner esa cabecera. Una petición enviada directamente al origen con una dirección inventada en esa cabecera se contaba bajo la dirección inventada. Cada límite por IP del producto volvía a cero con cada valor nuevo.
Añadir un límite por IP encima habría sido decoración. Así que la sesión empezó por la dirección misma.
Una sola función decide de dónde viene una petición
La corrección es una única función, client_ip(request), en un módulo nuevo. Responde a una pregunta: ¿qué salto nos entregó realmente esta petición, y podemos creer lo que ese salto afirma?
- Toma el salto que llegó a nuestro reverse proxy. Nuestro proxy añade la dirección que lo contactó al extremo derecho de
X-Forwarded-For, así que la entrada más a la derecha la escribe nuestra propia infraestructura, no el cliente. - Solo acepta
CF-Connecting-IPsi ese salto está dentro de los rangos de direcciones publicados por Cloudflare, y solo si el valor se lee como una dirección IP. - En cualquier otro caso, limita sobre el propio salto.
Los rangos de Cloudflare están copiados en el código, IPv4 e IPv6, tal como se publicaban el 6 de noviembre. Lo preferimos a descargarlos al arrancar: ninguna dependencia de red en el arranque, y Cloudflare anuncia sus cambios con mucha antelación.
Había una segunda corrección sobre la mesa, del lado de la red y no del código. Es un cambio de infraestructura, de nivel 1 según nuestras reglas: corresponde al CEO y se sigue aparte. El código ya no depende de ella: los límites se mantienen sea cual sea el camino de una petición.
Los límites de Scan and Talk se apoyan en esa función. Se cuentan por propietario y por dirección, no solo por propietario, porque un presupuesto global por propietario permitiría que un script lo agotara y bloqueara a los clientes reales del comercio. Son más amplios que los límites por dispositivo, porque clientes detrás de un mismo operador móvil suelen compartir una misma dirección pública. Un dispositivo que ya hizo cola con ese propietario hace poco no se vuelve a contar: un cliente real que vuelve a escanear conserva su sitio.
La prueba en producción, primera fase
El código llegó a producción a las 11:02 UTC. A las 11:03 UTC, Claude envió dos peticiones: una directamente al origen con un CF-Connecting-IP falsificado y otra a través de Cloudflare. Luego leyó los contadores.
Las dos peticiones se contaron bajo la dirección real de la máquina de prueba. No había ningún contador para la dirección falsificada, ni para la dirección interna del proxy, lo que también mostraba que la ruta por Cloudflare seguía respetando la cabecera.
Los límites se probaron después en una cuenta de prueba del CEO, tres minutos más tarde. Dispositivos aleatorios nuevos desde una misma dirección se aceptaron hasta el límite y luego se rechazaron con 429 scan_rate_limited. Un dispositivo ya conocido por ese propietario podía volver a escanear y recuperaba la misma solicitud. Tras una serie de rechazos desde una misma dirección, incluso un dispositivo conocido de esa dirección era rechazado. No se cobró nada. Efectos secundarios: unas pocas solicitudes y una notificación en los dispositivos de la cuenta de prueba. Las solicitudes que nadie respondió caducaron al cabo de un minuto.
Seis lecturas en bruto, una sola función
La primera sesión corrigió las rutas de Duo, porque su función sustituía a la función interna de Duo. La misma lectura en bruto existía en otros sitios. Una segunda sesión, más tarde ese mismo día, dentro de una cadena de sesiones aprobada por Juste, hizo pasar por client_ip() todas las lecturas restantes. Eran seis:
| Dónde | Antes | Qué alimentaba |
|---|---|---|
| chat, control de invitado | CF-Connecting-IP en bruto | dirección enviada a Turnstile |
| chat, límite de invitado | CF-Connecting-IP en bruto | el límite del chat de invitado |
| voz, inicio de llamada | CF-Connecting-IP en bruto | lectura del presupuesto diario de voz de invitado |
| voz, fin de llamada | CF-Connecting-IP en bruto | incremento de ese mismo presupuesto |
| formulario de contacto | primera entrada de X-Forwarded-For | el límite del formulario de contacto |
| atribución publicitaria | CF-Connecting-IP en bruto | la dirección enviada con los eventos de conversión |
El formulario de contacto era el peor de los seis. Creía la entrada más a la izquierda de X-Forwarded-For, la que escribe el cliente. Esa era falsificable incluso a través de Cloudflare.
El par de voz era sutil de otra manera. Inicio y fin leían la misma cabecera en bruto, así que coincidían entre sí. Coherentes, y los dos falsificables. Ahora calculan la misma clave con la misma función.
Una lectura se quedó como estaba, a propósito: una herramienta interna de administración que usa la dirección solo para escribir una entrada en el registro de auditoría, sin límite ni decisión asociados.
La prueba en producción, segunda fase
El segundo despliegue estaba en línea a las 11:36 UTC. Para probar la ruta de voz, Claude dejó agotado el presupuesto de voz de invitado de la dirección real de la máquina de prueba y luego envió dos solicitudes de llamada: una directamente al origen con un CF-Connecting-IP falsificado y otra a través de Cloudflare.
direct origin, forged CF-Connecting-IP : 402 {"detail":"guest_voice_daily_limit"}
via Cloudflare : 402 {"detail":"guest_voice_daily_limit"}Las dos se contaron bajo la dirección real. Antes del cambio, la petición falsificada habría leído el presupuesto vacío de la dirección inventada y habría obtenido una llamada. Dos lecturas de los contadores no encontraron ninguna clave para la dirección falsificada. Después se restauró el contador real.
El incidente durante la prueba
El log tiene una sección titulada « Incident pendant la preuve (de mon fait) »: incidente durante la prueba, por mi culpa. Es de Claude, y tiene su lugar en este artículo.
Dos veces, el paso que dejaba agotado el presupuesto falló, y Claude lanzó las sondas sin comprobar que había funcionado. El primer intento apuntaba a un puerto de base de datos que no es accesible desde fuera. El segundo guardaba un comando remoto en una variable de shell que el shell no separó en argumentos. Los dos fallaron sin ruido. Así que las sondas obtuvieron lo que obtendría un invitado sin límite: abrieron cuatro sesiones de voz de invitado reales.
Claude cerró las cuatro por la ruta normal de fin de llamada en menos de 15 segundos. Todas terminaron tras 11 a 12 segundos, sin ningún crédito cobrado, comprobado en la base de datos. El coste real: unos segundos de una sala de voz vacía y de un worker despachado, sin nadie en la llamada. Sus 45 segundos se sumaron al presupuesto de invitado de la dirección de prueba de Claude, y de nadie más.
La lección está escrita en el log tal como se aplicó en el tercer intento: « la mise en place passe dans un appel séparé, relue, avant toute sonde ». La preparación corre como un paso propio y se relee antes de cualquier sonda. Una sonda lanzada sobre una preparación no comprobada no prueba el límite. Prueba el estado, sea cual sea, que la preparación dejó.
Lo que sigue abierto
Dos rutas no están probadas en producción: el límite del chat de invitado y el formulario de contacto. El chat rechaza una petición de invitado sin un token de Turnstile válido antes de llegar al contador, así que una sonda automatizada no llega tan lejos. El formulario de contacto incrementa su contador después de enviar un correo real a Juste: probarlo significa escribirle desde una petición falsificada. Las dos llaman a la misma función que la ruta de voz probada arriba. Es una buena razón para esperar que se comporten bien. No es una prueba, así que siguen abiertas en el log, con la observación exacta que cada una necesita.
Lo que nos llevamos
El prompt acertaba en lo que había que construir y se equivocaba sobre el terreno en el que lo apoyaba. Si la sesión se hubiera fiado de su propio brief, habría entregado límites cuidadosos que una sola cabecera podía reiniciar. El paso más barato del día fue una petición enviada directamente a nuestro propio servidor, antes de escribir una sola línea.
El incidente es la misma lección, apuntada hacia nosotros. Una hipótesis sin comprobar, en un brief o en un comando de preparación, no se queda en hipótesis. Se convierte en lo que el sistema hace a continuación.