SistemasRedes

PhantomRelay: por qué tu automatización se delata antes del primer byte HTTP

Un cliente HTTP de Node es distinguible de Chrome real en el handshake TLS, antes de enviar una sola cabecera. Cómo construí un relay de navegador con un addon Rust sobre BoringSSL, escalación por costo y casi 900 tests.

Efrain Garay 15 de agosto de 2026

Tienes una suite de pruebas end-to-end que corre contra tu propio staging. Un día pones un WAF delante y la suite empieza a fallar: no por un bug tuyo, sino porque el WAF decidió que tu automatización no parece un navegador. Y tiene razón — pero no por lo que uno esperaría. No la detectó por el User-Agent ni por navigator.webdriver. La detectó antes: en el handshake TLS, antes de que se enviara la primera cabecera HTTP.

PhantomRelay nació de ese problema.

PhantomRelay en 24 segundos: el saludo TLS que te delata, por qué OpenSSL y BoringSSL no producen los mismos bytes, y por qué la solución fue heredar en vez de imitar. Sin sonido por defecto — actívalo en los controles.

El ClientHello te delata

Cuando abres una conexión TLS, lo primero que envías es un ClientHello: un mensaje en texto plano (aún no hay cifrado) que anuncia qué versiones soportas, qué cipher suites prefieres y en qué orden, qué extensiones traes, qué curvas elípticas aceptas y qué protocolos anuncias por ALPN.

Ese mensaje no es igual en todos los clientes, y ahí está el problema: su estructura depende de la librería TLS que lo genera. Node.js usa OpenSSL. Chrome usa BoringSSL — un fork propio de Google. Aunque configures ambos para “lo mismo”, el byte stream sale distinto.

Un observador toma ese ClientHello, lo resume en un hash, y ya sabe con qué está hablando. Eso es JA3. Su sucesor, JA4, agrega el orden de las extensiones y más detalle de ALPN, con lo que gana entropía — y se vuelve mucho más difícil de imitar.

Las diferencias que importan son cuatro:

  1. El orden de las cipher suites. No solo cuáles soportas: en qué orden las prefieres. Cada stack tiene el suyo.
  2. El orden de las extensiones. Aquí está el muro real. Node te deja configurar ciphers, ALPN y versiones, pero no expone en qué orden OpenSSL serializa las extensiones del ClientHello. Puedes acertar JA3 y aun así fallar JA4.
  3. ALPN. Chrome anuncia HTTP/2 antes de HTTP/1.1. Muchos clientes anuncian uno solo, o al revés — y eso entra en el hash.
  4. GREASE (RFC 8701). Chrome inserta valores reservados pseudoaleatorios entre sus ciphers y extensiones, a propósito, para que los middleboxes no se osifiquen asumiendo que la lista nunca cambia. Un stack que no los emite tiene una silueta distinta.

La decisión: heredar en vez de imitar

Se puede intentar parchear OpenSSL para que su salida se parezca a la de BoringSSL. Es una carrera perdida: cada versión de Chrome cambia algo, y el orden de las extensiones no está expuesto en la API de Node de todas formas.

La decisión de arquitectura fue la contraria: no imitar el ClientHello de Chrome — heredarlo. Un addon nativo de Node escrito en Rust con NAPI-RS, apoyado en boring (el binding de BoringSSL que mantiene Cloudflare) y hyper-boring para HTTP/2.

Son 377 líneas de Rust en cinco archivos. Configuran el conector con el perfil de Chrome (cipher suites, curvas, algoritmos de firma, ALPN, SCT y OCSP stapling, con verificación de certificado siempre activa) y devuelven el builder sin finalizar, a propósito, para que la capa HTTP instale sus propios callbacks antes de cerrarlo.

Lo elegante es lo que no hay que escribir: el orden de extensiones y los valores GREASE no se fabrican. Salen solos, porque los produce la misma librería que usa Chrome.

La huella no termina en TLS. Hay dos capas más:

  • HTTP/2: los SETTINGS de la conexión también identifican al cliente. Chrome usa un HEADER_TABLE_SIZE de 64 KB donde Node usa 4 KB, y ventanas iniciales mucho mayores. Se replican los valores del perfil.
  • Orden de cabeceras: HTTP/2 exige que los pseudo-headers vayan primero, pero el orden del resto es libre — y cada navegador tiene el suyo. Se fija la secuencia capturada de un Chrome real con mitmproxy.

El addon nativo es opcional, siempre

Una regla dura del repo: el paquete nativo nunca puede ser obligatorio. Se carga con try/catch y hay degradación elegante al camino TLS de Node si el binario no compiló, si la plataforma no calza o si Rust no está disponible. En los tests se declara externo, así que la suite corre sin compilar nada.

El monorepo (Turborepo + pnpm, 13 paquetes, TypeScript strict) tiene un DAG explícito de dependencias donde ese paquete cuelga como hoja opcional. Instalar el proyecto en una máquina sin toolchain de Rust funciona; simplemente pierdes una capa.

Escalar por costo, no por defecto

La segunda idea del diseño: usar el nivel más barato que funcione.

Hay cuatro modos, del más barato al más caro: una petición HTTP pura con el perfil de red correcto (decenas de milisegundos), un Chrome headless controlado por CDP, ese Chrome con comportamiento humano simulado, y por último un perfil de navegador persistente y envejecido.

En modo automático el orquestador entra por el más barato y solo escala cuando un detector determina que la respuesta no sirve. La mayoría de las veces no hace falta ni levantar un navegador. Es la diferencia entre 30 milisegundos y 15 segundos por petición, multiplicada por cada request de una suite de QA.

Todo esto está descompuesto con inyección de dependencias: el orquestador recibe la flota de navegadores, el detector, el controlador de proxies, el ejecutor de acciones y el gestor de sesiones como interfaces. La lógica de escalación se testea entera sin levantar Chrome.

Tres piezas de las que estoy contento

Rate limiting distribuido que se degrada solo

Ventana deslizante sobre un sorted set de Redis, resuelta en un MULTI de cuatro comandos: agregar el timestamp, purgar lo que quedó fuera de la ventana, contar y renovar el TTL. Si el conteo excede el límite, se quita la entrada recién insertada y se calcula cuándo se libera el cupo.

Lo bueno no es eso, es el fallback: el limitador de Redis compone internamente uno en memoria. Si Redis no responde, marca la conexión como caída y delega, sesgado a permitir. La conexión inicial nunca bloquea el arranque del servicio. Un Redis caído degrada la precisión del límite; no tumba el relay.

Histéresis en la salud de los proxies

Cada proxy lleva un promedio móvil exponencial de sus éxitos. El detalle está en cómo se emiten los eventos: degraded cuando el puntaje cruza hacia abajo un umbral, recovered cuando cruza hacia arriba otro más alto.

Dos umbrales distintos, no uno. Con un solo umbral, un proxy que oscila alrededor del límite emite un evento por request y satura el bus. Con histéresis, tiene que recuperarse de verdad para volver a considerarse sano. Y la comparación es entre el puntaje previo y el nuevo — se emite en la transición, no en el estado.

Un relay CDP que no entiende CDP

El puente WebSocket que expone el protocolo de Chrome hacia afuera reenvía los frames tal cual, sin interpretarlos. Es una decisión defensiva: si el relay generara mensajes propios, esos mensajes serían una señal. No interpretar nada es la garantía más simple de que no introduce artefactos.

Tuvo su costo de implementación: hubo que manejar el upgrade de HTTP a WebSocket a mano, porque la librería aborta el handshake en rutas que no son la suya y corrompía sockets ya promovidos por otra parte del servidor.

El caso de uso, hecho producto

La parte que más me gusta del proyecto es la que menos tiene que ver con el fingerprinting: un corredor de QA de unas 1.400 líneas que usa todo lo anterior para probar aplicaciones propias. Corre las suites, aísla el perfil del navegador por suite para que no se filtren cookies entre pruebas, y graba un MP4 por test ensamblado desde los frames del screencast de Chrome.

Es la mejor demostración de la tesis: la infraestructura existe para que la automatización legítima no se rompa.

Los números

  • 5 servicios en Docker: el relay, un Chrome dedicado, Redis, Prometheus y Grafana. Cada uno con healthcheck, y el relay esperando a que sus dependencias estén sanas.
  • ~900 tests en 56 archivos (el README todavía dice 732: quedó atrás).
  • ~20.600 líneas de TypeScript sin contar tests, más 377 de Rust.
  • El contenedor de Chrome corre con un perfil seccomp propio, porque Chromium necesita llamadas al sistema que el perfil por defecto de Docker no permite. La alternativa fácil habría sido --privileged o desactivar el sandbox; abrir lo mínimo cuesta más y es lo correcto.

Y las que faltan, dichas sin maquillaje: no hay benchmark público reproducible de tasa de éxito, y con proxy en el modo más barato se cae al camino TLS de Node, que no controla el orden de extensiones. Están en el roadmap, no en los logros.

Lo que me llevo

El fingerprinting TLS se discute casi siempre como tema de evasión. Me parece más interesante como problema de compatibilidad de stacks: dos librerías que implementan el mismo RFC producen bytes distinguibles, y ese detalle — invisible para el 99% del software — decide si tu suite de pruebas corre o falla.

La solución tampoco fue astucia: fue usar la misma pieza que usa el navegador y aceptar el costo de meter Rust en un monorepo de TypeScript, con el cuidado de que siga funcionando cuando ese Rust no está.

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.