¿Qué es Server-Side Request Forgery (SSRF), y cómo defiendes un endpoint que descarga una URL proporcionada por el usuario?
Un servidor que descarga una URL dada por el usuario puede terminar descargando tus propias credenciales de nube — el SSRF convierte tu posición de red en la del atacante.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es Server-Side Request Forgery (SSRF), y cómo defiendes un endpoint que descarga una URL proporcionada por el usuario?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué es realmente el SSRF
El Server-Side Request Forgery ocurre cuando un atacante logra que tu servidor haga una solicitud HTTP (u otro protocolo) en su nombre, hacia un destino que el atacante eligió y no hacia uno que tu aplicación pretendía. El montaje clásico es un endpoint que legítimamente necesita descargar una URL proporcionada por quien llama — un generador de vistas previas de enlaces, un probador de webhooks, una función de importación de imágenes, un renderizador de PDF a partir de una URL. El atacante no te entrega un archivo malicioso; te entrega una URL, y tu servidor hace la descarga usando su propia identidad de red, desde dentro de tu infraestructura.
Ese último detalle es lo que hace al SSRF peligroso y no simplemente molesto: tu servidor suele estar en un lugar que el atacante no puede alcanzar directamente — dentro de una VPC, detrás de un firewall, en la misma subred privada que paneles de administración internos, bases de datos y colas de mensajes. Muchos de esos servicios internos confían en cualquier cosa que pueda alcanzarlos, bajo el supuesto de que "si estás en esta red, ya pasaste alguna otra verificación". El SSRF le permite a un atacante externo tomar prestada, para una sola solicitud, la posición de confianza de tu servidor en esa red.
#Por qué los servicios de metadatos en la nube vuelven esto catastrófico
Todo proveedor de nube importante expone un servicio de metadatos de instancia en una dirección link-local (la más común es 169.254.169.254) que responde solicitudes HTTP sin autenticación a cualquier cosa que corra en esa instancia — por diseño, ya que su propósito es que tu propio código de aplicación pueda obtener su configuración y credenciales temporales sin necesitar un secreto pre-configurado. Si un endpoint vulnerable a SSRF descarga http://169.254.169.254/latest/meta-data/iam/security-credentials/<nombre-del-rol>, la respuesta suele incluir una clave de acceso temporal y activa junto con su secreto, para el rol de IAM que corre la instancia de cómputo — entregada directamente al atacante dentro de la "vista previa" que tu endpoint estaba construyendo.
#Un endpoint vulnerable
from flask import Flask, requestimport requestsapp = Flask(__name__)@app.route("/fetch-preview")def fetch_preview():url = request.args.get("url")# PELIGROSO: descarga cualquier URL que el llamador proporcione, usando el acceso de red del propio servidor.resp = requests.get(url, timeout=5)return resp.text[:2000]
No hay nada exótico en esta ruta de solicitud — no hay inyección, no hay entrada malformada. El error es que el servidor trata "una URL que el usuario me pidió descargar" como si fuera equivalente a "una URL hacia la cual estoy dispuesto a enviar mis propias credenciales y mi posición de red", y esas dos cosas no son lo mismo.
#Cómo arreglarlo: poner en lista blanca el destino, no solo la sintaxis
Una verificación de forma de la URL (¿empieza con https://?) no detiene casi nada — un atacante puede indicar http://169.254.169.254/... o un nombre de host que él controla y que resuelve a una IP privada, y de todos modos "parecerá" una URL normal. La solución tiene que validar dónde va a aterrizar realmente la solicitud, no solo cómo está formateada la cadena.
import ipaddressimport socketfrom urllib.parse import urlparseimport requestsHOSTS_PERMITIDOS = {"images.trusted-partner.com", "cdn.trusted-partner.com"}def es_ip_publica(ip: str) -> bool:addr = ipaddress.ip_address(ip)return not (addr.is_privateor addr.is_loopbackor addr.is_link_local # cubre 169.254.169.254, la dirección de metadatos en la nubeor addr.is_multicastor addr.is_reserved)def descarga_segura(url: str) -> requests.Response:parsed = urlparse(url)if parsed.scheme not in ("http", "https"):raise ValueError("esquema no permitido")if parsed.hostname not in HOSTS_PERMITIDOS:raise ValueError("host no está en la lista blanca")# Resolver antes de conectar, y verificar cada dirección devuelta, no solo la primera —# un nombre de host puede tener varios registros A/AAAA.ips_resueltas = {info[4][0] for info in socket.getaddrinfo(parsed.hostname, None)}if not all(es_ip_publica(ip) for ip in ips_resueltas):raise ValueError("el host resuelve a una dirección no pública")# allow_redirects=False: un 302 hacia http://169.254.169.254/... nunca debe seguirse en silencio.resp = requests.get(url, timeout=5, allow_redirects=False)if resp.is_redirect:raise ValueError("las redirecciones no se siguen para URLs proporcionadas por el usuario")return resp
#Poniendo la defensa en capas
Ningún control por sí solo es infalible, así que las defensas contra SSRF están pensadas para apilarse:
| Capa | Qué detiene |
|---|---|
| Lista blanca de host/esquema | Solicitudes a hosts arbitrarios o esquemas que no sean HTTP(S) (file://, gopher://, dict://) |
| Resolver y luego validar la IP | Nombres de host que resuelven a direcciones privadas, loopback o link-local |
| Desactivar el seguimiento de redirecciones, o revalidar cada salto | Un host en lista blanca que redirige hacia una dirección interna bloqueada |
| Firewall de salida a nivel de red en la capa de aplicación | Cualquier bypass en la lógica de aplicación, como respaldo |
| IMDSv2 (acceso a metadatos basado en token de sesión) | Lecturas ingenuas de metadatos que no tengan un token de sesión válido, aunque el SSRF ocurra igual |
| Un servicio "descargador" dedicado, de bajo privilegio y sin acceso a la red interna | El peor escenario, si todas las demás capas fallan |