@@ -43,6 +43,13 @@ public class RDAPClient : SingletonBase<RDAPClient>
4343
4444 private readonly HttpClient _client ;
4545
46+ /// <summary>
47+ /// Client for registrar referrals. The URL is sent by the registry and therefore untrusted. Direct connections
48+ /// are only made to the addresses that were checked when connecting (no second DNS lookup, see
49+ /// <see cref="ConnectToPublicAddressAsync" />), which prevents DNS rebinding to local or private addresses.
50+ /// </summary>
51+ private readonly HttpClient _referralClient ;
52+
4653 #endregion
4754
4855 #region Constructor
@@ -59,9 +66,22 @@ public RDAPClient()
5966 Timeout = Timeout . InfiniteTimeSpan
6067 } ;
6168
62- _client . DefaultRequestHeaders . UserAgent . ParseAdd ( "NETworkManager" ) ;
63- _client . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( RDAPMediaType ) ) ;
64- _client . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
69+ _referralClient = new HttpClient ( new SocketsHttpHandler
70+ {
71+ AllowAutoRedirect = false ,
72+ AutomaticDecompression = DecompressionMethods . All ,
73+ ConnectCallback = ConnectToPublicAddressAsync
74+ } )
75+ {
76+ Timeout = Timeout . InfiniteTimeSpan
77+ } ;
78+
79+ foreach ( var client in new [ ] { _client , _referralClient } )
80+ {
81+ client . DefaultRequestHeaders . UserAgent . ParseAdd ( "NETworkManager" ) ;
82+ client . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( RDAPMediaType ) ) ;
83+ client . DefaultRequestHeaders . Accept . Add ( new MediaTypeWithQualityHeaderValue ( "application/json" ) ) ;
84+ }
6585 }
6686
6787 #endregion
@@ -196,13 +216,19 @@ private async Task<RDAPResult> GetAsync(RDAPQuery query, string url, TimeSpan ti
196216
197217 try
198218 {
199- response = await _client . GetAsync ( currentUrl , timeoutSource . Token ) . ConfigureAwait ( false ) ;
219+ response = await ( isReferral ? _referralClient : _client ) . GetAsync ( currentUrl , timeoutSource . Token )
220+ . ConfigureAwait ( false ) ;
200221 body = await response . Content . ReadAsStringAsync ( timeoutSource . Token ) . ConfigureAwait ( false ) ;
201222 }
202223 catch ( OperationCanceledException ) when ( cancellationToken . IsCancellationRequested )
203224 {
204225 throw ;
205226 }
227+ catch ( HttpRequestException ex ) when ( ex . InnerException is RDAPException inner )
228+ {
229+ // Blocked by ConnectToPublicAddressAsync.
230+ throw new RDAPException ( inner . Kind , inner . Message , ex ) { RequestUrl = currentUrl . AbsoluteUri } ;
231+ }
206232 catch ( Exception ex ) when ( ex is HttpRequestException or OperationCanceledException )
207233 {
208234 var message = ex is OperationCanceledException ? $ "Timeout after { timeout . TotalSeconds } s." : ex . Message ;
@@ -261,17 +287,55 @@ private static async Task ValidateReferralTargetAsync(Uri url, CancellationToken
261287 {
262288 addresses = await Dns . GetHostAddressesAsync ( url . DnsSafeHost , cancellationToken ) . ConfigureAwait ( false ) ;
263289 }
264- catch ( SocketException )
290+ catch ( SocketException ex )
265291 {
266- // The request itself fails with a proper network error .
267- return ;
292+ // Fail closed: a referral is only followed if its address could be checked .
293+ throw new RDAPException ( RDAPErrorKind . Network , ex . Message , ex ) { RequestUrl = url . AbsoluteUri } ;
268294 }
269295 }
270296
271297 if ( addresses . Any ( IsLocalOrPrivateAddress ) )
272298 throw new RDAPException ( RDAPErrorKind . PrivateAddress , url . AbsoluteUri ) { RequestUrl = url . AbsoluteUri } ;
273299 }
274300
301+ /// <summary>
302+ /// Connect callback of the referral client: resolves the host once, checks all addresses and connects to exactly
303+ /// these addresses, so a second DNS answer (DNS rebinding) cannot redirect the connection to a local or private
304+ /// address. TLS still validates the certificate against the host name. If a proxy is used, the connection goes to
305+ /// the proxy, which resolves the target itself; in this case the check in
306+ /// <see cref="ValidateReferralTargetAsync" /> applies.
307+ /// </summary>
308+ private static async ValueTask < Stream > ConnectToPublicAddressAsync ( SocketsHttpConnectionContext context ,
309+ CancellationToken cancellationToken )
310+ {
311+ var endPoint = context . DnsEndPoint ;
312+
313+ // A tunnel (CONNECT) to the configured proxy. Referrals only use HTTPS, so a proxied connection is always a
314+ // tunnel.
315+ var isProxy = context . InitialRequestMessage . Method == HttpMethod . Connect ;
316+
317+ var addresses = IPAddress . TryParse ( endPoint . Host , out var ipAddress )
318+ ? [ ipAddress ]
319+ : await Dns . GetHostAddressesAsync ( endPoint . Host , cancellationToken ) . ConfigureAwait ( false ) ;
320+
321+ if ( ! isProxy && ( addresses . Length == 0 || addresses . Any ( IsLocalOrPrivateAddress ) ) )
322+ throw new RDAPException ( RDAPErrorKind . PrivateAddress , endPoint . Host ) ;
323+
324+ var socket = new Socket ( SocketType . Stream , ProtocolType . Tcp ) { NoDelay = true } ;
325+
326+ try
327+ {
328+ await socket . ConnectAsync ( addresses , endPoint . Port , cancellationToken ) . ConfigureAwait ( false ) ;
329+
330+ return new NetworkStream ( socket , true ) ;
331+ }
332+ catch
333+ {
334+ socket . Dispose ( ) ;
335+ throw ;
336+ }
337+ }
338+
275339 /// <summary>
276340 /// Loopback, private (RFC 1918, RFC 4193), shared (RFC 6598), link-local, unspecified and multicast addresses.
277341 /// </summary>
0 commit comments