using System.Net; using System.Net.Sockets; namespace JobTrackerApi.Services.JobImport; internal static class JobImportHttpTransport { // The optional dial function is a socket-boundary test seam, not a configurable policy. internal static SocketsHttpHandler CreateHandler( IHostAddressResolver resolver, Func>? connect = null) => new() { AutomaticDecompression = DecompressionMethods.All, AllowAutoRedirect = false, // A proxy would resolve/connect the origin outside this boundary. UseProxy = false, ConnectCallback = (context, cancellationToken) => ConnectAsync(context.DnsEndPoint, resolver, connect ?? ConnectSocketAsync, cancellationToken) // Return plain TCP; SocketsHttpHandler retains the original URI hostname for TLS/SNI. }; internal static bool IsBlockedAddress(IPAddress address) { if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0) return true; if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); if (IPAddress.IsLoopback(address)) return true; var bytes = address.GetAddressBytes(); if (address.AddressFamily == AddressFamily.InterNetwork) { return bytes[0] is 0 or 10 or 127 or >= 224 || (bytes[0] == 100 && bytes[1] is >= 64 and <= 127) || (bytes[0] == 169 && bytes[1] == 254) || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || (bytes[0] == 192 && bytes[1] == 168) || (bytes[0] == 198 && bytes[1] is 18 or 19); } if (address.AddressFamily != AddressFamily.InterNetworkV6) return true; if (address.Equals(IPAddress.IPv6Any) || address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || address.IsIPv6Multicast || address.IsIPv6Teredo) return true; if ((bytes[0] & 0xfe) == 0xfc) return true; // Unique local fc00::/7. // Reject legacy IPv4-compatible, well-known NAT64 and 6to4 destinations: their // eventual IPv4 peer is not represented by a native IPv6 socket destination. return bytes.AsSpan(0, 12).IndexOfAnyExcept((byte)0) < 0 || (bytes[0] == 0x00 && bytes[1] == 0x64 && bytes[2] == 0xff && bytes[3] == 0x9b && ((bytes[4] == 0 && bytes[5] == 1) || bytes.AsSpan(4, 8).IndexOfAnyExcept((byte)0) < 0)) || (bytes[0] == 0x20 && bytes[1] == 0x02); } private static async ValueTask ConnectAsync( DnsEndPoint endpoint, IHostAddressResolver resolver, Func> connect, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var addresses = IPAddress.TryParse(endpoint.Host, out var literal) ? [literal] : await resolver.ResolveAsync(endpoint.Host, cancellationToken); // Validate the entire answer before attempting any connection, including mixed answers. if (addresses.Length == 0 || addresses.Any(IsBlockedAddress)) throw new HttpRequestException("Local or private network URLs are not allowed."); SocketException? lastError = null; foreach (var address in addresses) { cancellationToken.ThrowIfCancellationRequested(); var normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; try { // No hostname is passed to the socket, so there is no second DNS lookup. return await connect(new IPEndPoint(normalized, endpoint.Port), cancellationToken); } catch (SocketException error) { lastError = error; } } throw new HttpRequestException("Could not connect to the import destination.", lastError); } internal static async ValueTask ConnectSocketAsync(IPEndPoint endpoint, CancellationToken cancellationToken) { var socket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; try { await socket.ConnectAsync(endpoint, cancellationToken); return new NetworkStream(socket, ownsSocket: true); } catch { socket.Dispose(); throw; } } }