7. Falsificación de peticiones del lado del servidor
Tu servidor hace una petición HTTP a una URL que eligió un atacante.
Aquí: esta API tiene exactamente un lugar donde quien llama aporta una URL: webhook_url, del Capítulo 18. Es fácil pasarlo por alto, y es el campo más peligroso del sistema, por las razones que dio ese capítulo.
Qué lo impide: una clase, usada en los dos extremos. Como regla de validación cuando se guarda la URL:
// app/Rules/PublicUrl.php
public function validate(
string $attribute,
mixed $value,
Closure $fail,
): void {
try {
self::resolve(is_string($value) ? $value : '');
} catch (UnsafeWebhookUrl) {
$fail('validation.public_url')->translate();
}
}
Y a través del propio resolve(), justo antes de cada entrega:
public static function resolve(string $url): string
{
$host = (string) parse_url($url, PHP_URL_HOST);
$records = $host === ''
? []
: dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false || $records === []) {
throw new UnsafeWebhookUrl;
}
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'];
if (! self::isPublic($ip)) {
throw new UnsafeWebhookUrl;
}
}
return $url;
}
private const REFUSED = [
'64:ff9b::/96', '64:ff9b:1::/48', // NAT64
'::ffff:0:0:0/96', '::/96', // IPv4 dentro de IPv6
'3fff::/20', '5f00::/16', 'fec0::/10',
'ff00::/8', '224.0.0.0/4', // multicast
];
private static function isPublic(string $ip): bool
{
$global = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_GLOBAL_RANGE,
) !== false;
return $global && ! IpUtils::checkIp($ip, self::REFUSED);
}
Busca todas las direcciones a las que resuelve el host, IPv4 e IPv6, y rechaza la URL si alguna de ellas no es una dirección pública. Comprobar solo la primera dirección es el error clásico: un host puede responder con una dirección pública y una interna.
FILTER_FLAG_GLOBAL_RANGE rechaza las redes privadas, el loopback, las direcciones de enlace local, incluida la dirección de metadatos de la nube, y la mayoría de los demás rangos reservados. Deja pasar unos pocos, y la constante los nombra: los prefijos NAT64, IPv4 traducido e IPv4 compatible, que pueden alcanzar direcciones IPv4 privadas desde una red IPv6, dos rangos reservados para documentación y experimentos de enrutamiento, un viejo rango de sitio local y multicast. IpUtils es la clase que usa la propia comprobación de proxies de confianza de Laravel.
Con url:https en la regla y withoutRedirecting() en el cliente, eso cierra los ataques comunes. No los cierra todos, y una revisión debería decir cuáles. Entre esta comprobación y la conexión, el cliente HTTP resuelve el nombre otra vez, y un atacante que controla el servidor DNS puede responder distinto la segunda vez. El resolvedor del cliente también lee el archivo hosts del propio servidor, que dns_get_record() nunca ve.
El arreglo completo es conectarse a la dirección que comprobaste. cURL puede fijar un host a una dirección para una petición:
->withOptions(['curl' => [
CURLOPT_RESOLVE => ["{$host}:{$port}:{$ip}"],
]])
Haz que resolve() devuelva la dirección que aprobó, pásala aquí, y el nombre no se busca una segunda vez. $port es el puerto de la URL, o 443 cuando no lo hay: una fijación para el puerto equivocado no fija nada. Una dirección IPv6 va entre corchetes. El otro arreglo completo es enviar todo el tráfico de webhooks a través de un proxy que rechace los destinos internos. Si los webhooks importan para tu producto, haz una de las dos cosas.
Cómo lo sabes: busca en el código cada llamada HTTP saliente y, para cada una, pregunta de dónde sale la URL. Una URL de la configuración está bien. Una URL de una columna de la base de datos que una petición puede escribir es esta vulnerabilidad hasta que se demuestre lo contrario.