El 4 de agosto la herramienta empezó a comprobar el estado de los enlaces externos, y lo hace por
defecto. Es una función corta de describir: por cada enlace que sale de tu sitio, una petición
HEAD para saber si el destino sigue vivo, sin leer nunca el cuerpo y sin seguir redirecciones.
También cambia lo que la herramienta es. Hasta ese día solo pedía páginas tuyas. Desde ese día pide URLs que ha escrito otro.
Lo que encontró la revisión
El único filtro antes de pedir una URL a un tercero era el esquema. Si empezaba por http o por
https, se pedía. No había nada más.
Con eso, un sitio bajo auditoría puede poner esto en cualquiera de sus páginas:
<a href="http://169.254.169.254/latest/meta-data/">.</a>
Y la herramienta lo pide desde el ordenador de quien está auditando. Da igual que la respuesta no se guarde: en el fichero del rastreo queda el estado, el tiempo de respuesta y un mensaje de error lo bastante preciso como para distinguir una conexión rechazada de un tiempo agotado.
Eso es un mapa. Dice qué direcciones tienen algo escuchando y cuáles no, y con cuánta latencia responden. Repartiendo enlaces por un rango privado entero, un sitio malicioso dibuja la red de la oficina de una consultoría con un rastreo rutinario y sin que suene nada.
El sitio del agujero es lo que lo empeora: el .sqlite del rastreo es exactamente el fichero que el
producto está pensado para enviar al cliente.
La primera criba
El arreglo salió en la 0.5.0. Antes de sondar una URL, mirar su host: se rechaza toda dirección que
no sea globalmente enrutable, y se rechazan los nombres localhost, *.local y *.internal.
Después entraron .lan, home.arpa, .corp, fritz.box y los nombres cortos que resuelven dentro
de un clúster o de una instancia de nube.
La criba no es incondicional, y eso está decidido: se apaga cuando el sitio auditado es él mismo
local, porque auditar un astro dev en localhost o el pre de un cliente en la LAN de la oficina
significa que quien lanzó el rastreo ya está dentro de esa red. La excepción de la excepción es el
rango de metadatos de nube, que se criba siempre. Un rastreo de localhost desde un runner de CI es
una cosa que pasa, y esa dirección responde con las credenciales de la instancia.
Salió a la calle con un comentario en el código que decía la verdad: la criba es léxica, y un nombre que resuelva a una dirección privada pasa. Estaba escrito antes de publicarlo. Se publicó igual.
La segunda revisión
Existen servicios públicos de DNS comodín. Se montaron para que un desarrollador pruebe subdominios
sin tocar /etc/hosts, llevan años en pie y no hay que registrar nada ni ser dueño de nada para
usarlos:
localtest.meylvh.meresuelven a127.0.0.1.nip.ioysslip.ioresuelven a la dirección que escribas dentro del propio nombre.192.168.1.10.nip.ioresuelve a192.168.1.10, y10.0.0.5.sslip.ioa10.0.0.5.
Ninguno de esos cuatro nombres es una dirección privada. Ninguno termina en .local ni se llama
localhost. La criba los deja pasar todos, porque lee texto y el texto es impecable.
Esto no se dedujo, se ejecutó: un servicio escuchando en loopback, la criba encendida, la sonda de
nuestro propio binario. HTTP 200.
Y el remate fue 169.254.169.254.nip.io. Ese nombre atraviesa también la excepción de metadatos de
nube, la única que el código declaraba innegociable, añadida esa misma mañana.
Por qué el segundo intento fue peor que el primero
El error de diseño no fue publicar una criba parcial sabiendo que lo era. Fue lo que se hizo cuando
aparecieron los primeros huecos: reforzarla. .lan, home.arpa, .corp, unos cuantos nombres
cortos más. Cada añadido es correcto por separado y ninguno acerca la solución, porque los cuatro
responden a la misma pregunta —cómo se llama este host— y la respuesta no está dentro de esa
pregunta.
Reforzar algo que funciona a medias se parece muchísimo a arreglarlo. Da trabajo, da diff, deja tests en verde y el hueco sigue donde estaba. Cuatro días antes nos había pasado lo mismo con las páginas huérfanas, donde la primera corrección tocó el sitio en el que se veía el daño en vez del sitio que lo causaba. Se repite porque el arreglo del síntoma siempre queda más cerca de la mano.
El arreglo que sí
Un nombre no dice a dónde apunta. Lo dice el resolutor, y solo después de haber resuelto. Así que la decisión se movió ahí: la resolución pasa ahora por un resolutor propio que filtra las direcciones antes de que ninguna llegue a un socket. El texto del host ya no decide nada.
Con una regla que parece excesiva y no lo es. Si una sola de las direcciones de un nombre está fuera de límites, se rechaza el nombre entero. Quedarse con las públicas y tirar las privadas suena más fino y en realidad le deja elegir al DNS, que es media parte de un ataque de rebinding: el nombre devuelve dos direcciones, tú te quedas con la buena, y la siguiente consulta devuelve solo la otra.
La criba léxica se queda de primera línea, y no por prudencia. Una IP escrita a mano dentro de un
href nunca llega a un resolutor, así que hay un caso entero que solo ve ella.
En la misma revisión salieron cinco cosas más por la misma puerta, y una merece contarse porque
explica por qué el perímetro tiene que estar en un solo sitio: la clave follow_external del
fichero de configuración se consultaba únicamente en el camino de la sonda, así que un rastreo con
ese alcance llegaba a las direcciones que la sonda tenía prohibidas, y encima con un GET completo
cuyo cuerpo se parseaba y se guardaba. Al ejecutar el caso, el título y el <h1> de un panel de
Kibana interno acabaron escritos en el fichero del rastreo.
Cómo comprobar el tuyo
Si mantienes cualquier cosa que pide URLs por cuenta de otro —un validador de enlaces, un previsualizador de tarjetas sociales, un webhook con URL configurable, un importador de feeds—, comprobar esto cuesta un minuto:
curl -s -o /dev/null -w '%{http_code}\n' \
'https://tu-servicio.example/comprobar?url=http://127.0.0.1.nip.io:8080/'
Y la que de verdad duele, si tu servicio corre en una instancia de nube:
http://169.254.169.254.nip.io/latest/meta-data/
Si tu filtro mira el nombre del host, las dos entran. Que te devuelva cualquier cosa distinta de un rechazo significa que tienes el mismo diseño que teníamos nosotros hace una semana.
Lo que aprendimos cabe en una frase, y la escribimos en el código para que nadie la deshaga dentro de un año: una criba léxica no puede defender de un nombre.