Protección contra SSRF

Cuando su aplicación descarga una URL proporcionada por el usuario, un atacante puede aprovecharlo para llegar a su red interna. Las clases UrlValidator e IPAddress le ayudan a protegerse de estos ataques Server-Side Request Forgery (SSRF).

Instalación y requisitos

¿Qué es el SSRF?

Imagine una funcionalidad en la que el usuario introduce una URL y su servidor la descarga: un avatar de una dirección remota, el destino de un webhook, la vista previa de un enlace. Parece inofensivo, pero quien accede a la dirección es el servidor, no el navegador del usuario. Y el servidor ve sitios que el atacante no ve: la interfaz de loopback, la red privada, los servicios en la nube.

Por eso el atacante envía una URL que apunta hacia dentro en lugar de a la internet pública. Los objetivos típicos son:

  • los metadatos de la nube en http://169.254.169.254/, que pueden filtrar claves de acceso
  • paneles de administración internos y routers como http://192.168.1.1/
  • servicios sin autenticación, como Redis en http://localhost:6379/

Esta clase de vulnerabilidad es tan habitual que figura en el OWASP Top 10. La defensa consiste en validar la URL antes de descargarla y rechazar todo lo que se resuelva a una dirección no pública.

UrlValidator

Nette\Http\UrlValidator comprueba una URL frente a una política configurable: el esquema, el puerto, el host, la información de usuario y las direcciones IP a las que se resuelve el host. El uso básico es una sola llamada:

use Nette\Http\UrlValidator;

if (!(new UrlValidator)->allows($userUrl)) {
	return; // URL insegura, no la descargue
}

La política predeterminada es deliberadamente estricta: solo acepta https en el puerto 443 apuntando a una dirección IP pública. Todo lo demás (loopback, rangos privados, link-local incluidos los metadatos de la nube, rangos reservados) se rechaza, y el multicast se rechaza sin condiciones. Es el punto de partida correcto para descargar URL arbitrarias proporcionadas por el usuario.

Configurar la política

La política se moldea con el constructor. Por ejemplo, para permitir http a secas en cualquier puerto y llegar a direcciones privadas (útil dentro de una red de confianza):

$validator = new UrlValidator(
	schemes: ['http', 'https'],
	ports: null, // cualquier puerto
	allowPrivateIps: true,
);

Un patrón habitual es limitar la descarga a un conjunto fijo de dominios asociados mediante una lista blanca de hosts. El prefijo *. casa con cualquier profundidad de subdominio, pero no con el dominio raíz; indique ambas formas si lo necesita:

$validator = new UrlValidator(
	hostAllowlist: ['example.com', '*.example.com'],
);

El conjunto completo de opciones del constructor:

Parámetro Predeterminado Significado
schemes ['https'] esquemas permitidos; [] lo rechaza todo
ports [443] puertos permitidos, null = cualquiera; se respeta el puerto implícito del esquema
allowPrivateIps false permite los rangos privados (10/8, 172.16/12, 192.168/16, fc00::/7)
allowLoopback false permite el loopback (127.0.0.0/8, ::1)
allowLinkLocal false permite link-local, incluidos los metadatos de la nube 169.254.169.254
allowReserved false permite los rangos reservados por la IANA
allowUserinfo false permite user:pass@ en la URL
hostAllowlist null si se indica, el host debe encajar con algún patrón; [] los rechaza todos
hostBlocklist null si se indica, el host no debe encajar con ningún patrón

Métodos de validación

El validador ofrece tres métodos. allows() ejecuta la comprobación completa, incluida la resolución DNS: el host se resuelve y todas las direcciones A/AAAA deben pasar la política de IP:

(new UrlValidator)->allows($url); // bool

allowsWithoutDns() se salta la resolución DNS y las comprobaciones de rangos de IP. Úselo como prefiltro rápido, o cuando la validación DNS se delega en la capa de descarga:

(new UrlValidator)->allowsWithoutDns($url); // bool

Ambos métodos aceptan una cadena, un objeto UrlImmutable o null (que siempre falla).

Derrotar el DNS rebinding

Hay una sutil carrera entre la validación y la descarga: un atacante puede devolver una IP segura cuando valida el host y después cambiar el DNS a una IP interna para la descarga real. Para cerrar ese agujero, getResolvedIPs() devuelve las direcciones IP validadas, y usted fija la conexión a ellas para que la descarga no se pueda redirigir a otro sitio:

$ips = (new UrlValidator)->getResolvedIPs($url);
if (!$ips) {
	return; // URL insegura
}

$ch = curl_init($url);
$host = parse_url($url, PHP_URL_HOST);
curl_setopt($ch, CURLOPT_RESOLVE, ["$host:443:" . implode(',', $ips)]);
// ... ejecuta la petición

El método devuelve un array de cadenas con las IP (primero los registros A, después los AAAA) que pasaron la política completa, o un array vacío ante cualquier fallo. Si la URL contiene una IP literal, valida la dirección directamente y no hace ninguna consulta DNS.

IPAddress

Nette\Http\IPAddress es un objeto de valor inmutable para trabajar con direcciones IPv4 e IPv6. UrlValidator lo usa internamente, pero también resulta práctico por sí solo siempre que clasifique direcciones. El constructor lanza Nette\InvalidArgumentException si la dirección no es válida:

use Nette\Http\IPAddress;

$ip = new IPAddress('169.254.169.254');
echo $ip; // '169.254.169.254'

Cuando no quiera una excepción, use la fábrica tryFrom() o el comprobador isValid():

$ip = IPAddress::tryFrom($input); // ?IPAddress
IPAddress::isValid($input);       // bool

Clasificación de las direcciones

Los predicados le dicen a qué clase pertenece una dirección. El clave es isPublic(): verdadero solo para las direcciones enrutables públicamente, que es exactamente lo que quiere una protección contra SSRF:

$ip = new IPAddress('169.254.169.254');
$ip->isPublic();    // false
$ip->isLinkLocal(); // true (rango de metadatos de la nube)

El conjunto completo de predicados:

Método Comprueba
isPublic() enrutable públicamente (ninguno de los siguientes)
isPrivate() rangos privados RFC 1918 / 4193
isLoopback() 127.0.0.0/8, ::1
isLinkLocal() 169.254.0.0/16 (incl. metadatos de la nube), fe80::/10
isMulticast() 224.0.0.0/4, ff00::/8
isReserved() reservados por la IANA (documentación, CGNAT, uso futuro, …)

Pertenencia a un rango

isInRange() comprueba si la dirección cae dentro de un bloque CIDR. Puede pasar una red con prefijo, o una dirección a secas para una coincidencia exacta (/32 implícito para IPv4, /128 para IPv6):

$ip = new IPAddress('192.168.1.50');
$ip->isInRange('192.168.0.0/16'); // true
$ip->isInRange('10.0.0.1');       // false (coincidencia exacta)

Una entrada mal formada o de otra familia de IP devuelve false.

IPv6 con IPv4 mapeada

Las direcciones escritas como IPv6 con IPv4 mapeada (como ::ffff:127.0.0.1) son una forma clásica de colarse por filtros ingenuos. IPAddress las normaliza, así que los predicados de rango ven a través del disfraz:

$ip = new IPAddress('::ffff:127.0.0.1');
$ip->isLoopback();   // true
$ip->isIPv4Mapped(); // true
$ip->toIPv4();       // IPAddress('127.0.0.1')

Los métodos isIPv4() e isIPv6() informan de la forma textual: una dirección mapeada es IPv6, no IPv4.

versión: 4.x