using System.Net; using System.Net.Sockets; namespace MaBibli.Api.Services.Couvertures; /// /// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables. /// /// /// /// ⚠️ Ce garde est la raison pour laquelle le proxy de couvertures est acceptable. Le /// service .NET écoute sur 127.0.0.1 et vit sur un serveur YunoHost qui héberge d'autres /// applications, souvent sur des ports locaux. Un proxy qui irait chercher n'importe quelle URL /// permettrait de faire lire au serveur ce que le client ne peut pas atteindre lui-même — /// c'est-à-dire exactement ce que la contrainte « écouter sur 127.0.0.1 uniquement » cherche à /// empêcher. /// /// /// ⚠️ La vérification est faite au moment de CONNECTER, pas avant la requête. Résoudre le /// nom d'abord puis laisser HttpClient résoudre à nouveau laisserait passer un /// DNS rebinding : un nom qui répond une adresse publique à la vérification et /// 127.0.0.1 à la connexion. Ici, la socket se connecte aux adresses déjà validées, /// et à aucune autre. /// /// public static class GardeAdresses { /// /// Rappel de connexion pour . /// public static async ValueTask ConnecterAsync( SocketsHttpConnectionContext contexte, CancellationToken ct) { var adresses = await Dns.GetHostAddressesAsync(contexte.DnsEndPoint.Host, ct); var permises = adresses.Where(EstPubliquementRoutable).ToArray(); if (permises.Length == 0) { // Message volontairement muet sur ce qui a été résolu : l'appelant n'a pas à // apprendre la topologie du réseau du serveur. throw new HttpRequestException("Adresse non autorisée."); } var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; try { await socket.ConnectAsync(permises, contexte.DnsEndPoint.Port, ct); return new NetworkStream(socket, ownsSocket: true); } catch { socket.Dispose(); throw; } } /// /// Vrai si l'adresse est publiquement routable, c'est-à-dire hors de tout ce qui pourrait /// désigner le serveur lui-même ou son réseau local. /// /// /// Liste négative, contrairement à la plupart des filtres du projet : ici, laisser /// passer une plage oubliée est une faille, alors qu'en écarter une de trop ne coûte qu'une /// couverture non mise en cache. /// public static bool EstPubliquementRoutable(IPAddress adresse) { // Une adresse IPv4 encapsulée en IPv6 (::ffff:127.0.0.1) doit être jugée sur son IPv4, // sans quoi le bouclage passerait déguisé. if (adresse.IsIPv4MappedToIPv6) { adresse = adresse.MapToIPv4(); } if (IPAddress.IsLoopback(adresse)) { return false; } if (adresse.AddressFamily == AddressFamily.InterNetwork) { var o = adresse.GetAddressBytes(); return o[0] switch { 0 => false, // 0.0.0.0/8 « ce réseau » 10 => false, // 10.0.0.0/8 privé 127 => false, // bouclage (déjà couvert, par sûreté) 169 when o[1] == 254 => false, // 169.254.0.0/16 lien-local (métadonnées cloud) 172 when o[1] >= 16 && o[1] <= 31 => false, // 172.16.0.0/12 privé 192 when o[1] == 168 => false, // 192.168.0.0/16 privé 192 when o[1] == 0 && o[2] == 0 => false, // 192.0.0.0/24 usages protocolaires 100 when o[1] >= 64 && o[1] <= 127 => false, // 100.64.0.0/10 CGNAT >= 224 => false, // multicast et réservé _ => true, }; } if (adresse.AddressFamily == AddressFamily.InterNetworkV6) { if (adresse.IsIPv6LinkLocal || adresse.IsIPv6SiteLocal || adresse.IsIPv6Multicast || adresse.Equals(IPAddress.IPv6Any)) { return false; } // fc00::/7 : adresses locales uniques (l'équivalent IPv6 du privé). return (adresse.GetAddressBytes()[0] & 0xFE) != 0xFC; } // Ni IPv4 ni IPv6 : on ne sait pas juger, donc on refuse. return false; } }