Saltar al contenido principal
Jordi Izquierdo Montalbán
Blog

Un guardián anti-SSRF para un agente que navega por internet

Cómo protegí a un agente con navegación abierta: resolver el DNS, mirar todas las IP, conectar a la IP validada y por qué un mensaje de error me enseñó a saltármelo.

7 min de lectura -

El investigador de mi bot de Telegram (agente-telegram) descarga páginas que elige el modelo. No hay lista blanca de dominios: lo decidí a propósito en el ADR-007. Así que cualquier página o correo que consiga que el agente siga un enlace puede mandarlo a donde quiera, y el agente está dentro: ve lo que se ve desde la máquina donde corre, no lo que se ve desde fuera.

El riesgo

Si el agente puede pedir http://169.254.169.254/, llega a la dirección donde muchas nubes sirven las credenciales de la máquina; en configuraciones antiguas o sin endurecer basta un GET sin más. Es el objetivo clásico. Si el agente corre en casa y puede pedir http://192.168.1.1/, habla con el panel de mi router; si corre en una VPS, el objetivo es el endpoint de metadatos. Eso es SSRF: el destino de una petición lo decide alguien que no es el código.

Descarté la lista blanca porque rompe la función (investigar es no saber dónde está la respuesta) y porque un dominio de confianza no garantiza contenido de confianza. A cambio la decisión depende de dos cosas: que el investigador no pueda actuar (no escribe, no envía) y que exista un control técnico para las direcciones internas. Ese control es red_segura.py.

Qué comprueba

Antes de abrir ninguna conexión, validar_url exige:

  • Esquema: solo http y https, con lista blanca. Con lista negra tendría que acertar todo lo malo.
  • Sin credenciales en la URL: http://ejemplo.com@127.0.0.1/ va al 127.0.0.1.
  • Nombre: rechazo localhost y sufijos como .local, .internal o .lan sin preguntar al DNS.
  • Dirección: nada de loopback, enlace local, red privada, multicast ni reservadas. Cuatro direcciones de metadatos de nube tienen motivo propio en el registro.
  • Puerto: solo 80, 443, 8080 y 8443. Esto no me protege a mí; evita que el bot sirva de herramienta contra terceros. Lo dejé escrito así para no engañarme sobre qué compro.

Mirar todas las IP

Mirar el texto de la URL detecta 127.0.0.1 y nada más: http://midominio.com puede resolver a 10.0.0.5. Por eso resuelvo el nombre y miro todas las direcciones:

resolucion = resolver or _resolver_de_verdad
direcciones = list(dict.fromkeys(resolucion(host, puerto)))  # sin repetidos, en orden
if not direcciones:
    raise NombreNoResuelve("No se ha podido resolver el nombre del servidor.")
 
for candidata in direcciones:
    motivo = _motivo_si_es_interna(candidata)
    if motivo:
        raise DestinoInterno("El destino resuelve a una dirección interna.", motivo)

Basta que una sea interna para rechazar. Si aceptara por tener alguna pública, el destino real sería una tirada de dados que elige quien controla el DNS.

DNS rebinding: aprobar un destino, no un nombre

El guardián resuelve y aprueba, y luego httpx vuelve a resolver para conectar. En esa ventana, quien controla el DNS de su dominio devuelve una IP pública en la comprobación y 127.0.0.1 en la conexión. Habría aprobado un nombre, no un destino. Mi solución es conectar a la IP que aprobó el guardián:

logica = httpx.URL(validada.url)
fijada = logica.copy_with(host=validada.direcciones[0])
 
cabeceras = {"User-Agent": IDENTIFICACION, "Host": logica.netloc.decode("ascii")}
extensiones = {"sni_hostname": logica.host} if logica.scheme == "https" else {}
 
return cliente.stream(
    "GET",
    fijada,
    headers=cabeceras,
    timeout=SEGUNDOS_DE_ESPERA,
    follow_redirects=False,
    extensions=extensiones,
)

La URL lleva la IP, la cabecera Host lleva el nombre y, en https, sni_hostname también, de modo que el certificado se sigue comprobando contra el nombre. Sin el SNI el handshake falla siempre, y el arreglo tentador sería apagar la verificación: cambiar un agujero por otro mayor.

Lo verifiqué leyendo httpcore/_sync/connection.py (httpcore 1.0.9), que usa sni_hostname or origin.host, y en real el 03/09/2026 contra www.aemet.es: con la IP y el SNI correcto responde 200; sin SNI o con el equivocado, el handshake se cae.

La redirección era el agujero grande

En el ADR-007 original dejé el rebinding como límite abierto, por ser "mucha maquinaria". Me equivoqué en dos cosas: eran unas veinte líneas, y el agujero grande era la redirección. Una página tuya devuelve 302 Location: http://169.254.169.254/ y mi cliente, que lo tenía con follow_redirects=True, la pide. httpx no sigue redirecciones por defecto: el agujero lo abría mi propio código. No hace falta tocar ningún DNS.

Ahora follow_redirects=False (antes estaba en True) y sigo los saltos a mano, hasta cinco, validando cada uno. En el test lo que vale es la primera comprobación: que salte la excepción no sirve si saltó después de pedir las credenciales.

# (abreviado)
_, fallo, registro = intentar(
    "https://una-web-normal.com/articulo",
    [httpx.Response(302, headers={"location": "http://169.254.169.254/latest/meta-data/"})],
)
 
destinos = [peticion.url.host for peticion in registro]
assert "169.254.169.254" not in destinos
assert fallo.motivo == "metadatos_de_nube"

El fallo: un error que enseña a saltarse la protección

Esto pasó en reto (JIMMY), donde la herramienta resumir_url sí usa lista blanca de dominios. Probando el bot a mano le pedí resumir https://192.168.1.1/, mi router. Bloqueó bien, pero el mensaje decía que había que añadir el dominio a DOMINIOS_PERMITIDOS_WEB. Mi guardián enseñaba a saltárselo.

Una IP privada no está en la lista blanca, así que la petición se paraba en esa primera comprobación, sin llegar a la que sabe que es interna. Lo corregí mirando lo interno antes de hablar de la lista, y el mensaje ni siquiera nombra la variable: el modelo se quedaría con el nombre y podría acabar sugiriéndosela.

Cinco días después (07/08) pasó lo mismo con localhost, que no es una IP literal. El bloqueo funcionaba; el fallo era de mensaje. Un arreglo cierra un camino, no una clase: la pregunta útil no es "¿está arreglado?" sino "¿por dónde más se entra aquí?". Yo lo clasificaría como diseño inseguro (A06 en OWASP 2025): cuando quien lee el error es un modelo, el mensaje es superficie de ataque. Por eso en agente-telegram los errores no repiten la URL ni el puerto.

Y reto declara por escrito que el DNS rebinding no se cierra ahí: lo compensa con la lista blanca, porque el atacante tendría que controlar además un dominio que yo haya autorizado. La mitigación del rebinding está en la ruta de descarga de agente-telegram, que al no tener lista blanca no puede apoyarse en esa compensación.

La inyección de prompt, por estructura

El ADR-007 descarta filtrar patrones de inyección: un filtro que acierta el 99% deja pasar justo la que importa y da falsa sensación de protección. Lo sólido es estructural. En saneado.py el texto de terceros entra al prompt dentro de una cita con una marca aleatoria de 16 dígitos hexadecimales, distinta en cada cita y generada con secrets. Con una etiqueta fija bastaría que la página la escribiera para salirse; con una marca que no se conoce y no se repite, el contenido no puede acertar el cierre de la cita. Eso impide que el texto se haga pasar por estructura; no garantiza que el modelo ignore lo que dice dentro.

El contraste: apis-keys

En mi skill apis-keys, fetch.py descarga URLs de proveedores con otro modelo: allowed_domains obligatorio, solo https y puerto 443, salvo localhost para proveedores locales como Ollama, y una redirección solo se sigue si su destino está en la lista. No resuelve nombres ni mira IP. Ahí la defensa es la lista blanca, no la resolución, y para un conjunto cerrado de dominios es coherente. Pero no cierra SSRF por sí sola: un dominio autorizado podría acabar apuntando a una IP interna, que es justo lo que reto cubre con su segunda comprobación. No lo he medido; lo deduzco de leer el código.

Lo que me llevo

Casi todo lo anterior es el mismo error: comprobar una cosa y actuar sobre otra (el nombre contra la IP, la URL inicial contra la redirección, el mensaje contra lo que hace el modelo). Y el fallo del mensaje no lo vio ningún test: lo vi usando el bot.

Compartir