diff --git a/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs b/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs index a39b5912e6e1..a944ffd8ba83 100644 --- a/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs +++ b/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs @@ -20,6 +20,10 @@ public class OpenApiFunctionExecutionParameters /// /// The default client does not follow redirects so that URL validation cannot be bypassed. /// Custom clients should also be configured with AllowAutoRedirect = false. + /// In the .NET 8 and .NET 10 builds, the default client pins direct operation connections + /// to validated DNS addresses. Custom clients own their connection-time DNS validation; + /// setting AllowAutoRedirect = false alone does not prevent DNS rebinding. + /// Proxy-mediated requests and the .NET Standard 2.0 build are not pinned. /// public HttpClient? HttpClient { get; set; } diff --git a/dotnet/src/Functions/Functions.OpenApi/OpenApiHttpClientProvider.cs b/dotnet/src/Functions/Functions.OpenApi/OpenApiHttpClientProvider.cs new file mode 100644 index 000000000000..e164169be944 --- /dev/null +++ b/dotnet/src/Functions/Functions.OpenApi/OpenApiHttpClientProvider.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft. All rights reserved. + +#if NET +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +#endif +using System.Net.Http; +#if NET +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +#else +using Microsoft.SemanticKernel.Http; +#endif + +#pragma warning disable CA2000 // Ownership is transferred to the shared handler or NetworkStream. + +namespace Microsoft.SemanticKernel.Plugins.OpenApi; + +/// +/// Creates the built-in OpenAPI HTTP client and binds validated DNS addresses to its connections. +/// +internal static class OpenApiHttpClientProvider +{ +#if NET + internal static readonly HttpRequestOptionsKey ValidatedAddressesKey = new("OpenApiValidatedAddresses"); + + // RFC 8305's 250 ms delay starts another route (e.g., IPv4) without timing out the first (e.g., IPv6). + private static readonly TimeSpan s_connectionAttemptDelay = TimeSpan.FromMilliseconds(250); +#endif + + /// + /// Creates a non-redirecting client backed by a shared handler with separate pinned and unchecked connection pools. + /// + public static HttpClient CreateHttpClient() + { +#if NET + return new HttpClient(ValidatedAddressHandler.Instance, disposeHandler: false); +#else + return HttpClientProvider.GetNonRedirectingHttpClient(); +#endif + } + +#if NET + /// + /// Keeps pinned operations from reusing unchecked connections opened for document loading or explicit policy bypasses. + /// + private sealed class ValidatedAddressHandler : HttpMessageHandler + { + // Plugins have no disposal contract; keep pool ownership at assembly scope rather than allocating pools per plugin. + public static ValidatedAddressHandler Instance { get; } = new(); + + private readonly HttpMessageInvoker _pinnedClient; + private readonly HttpMessageInvoker _unpinnedClient; + + private ValidatedAddressHandler() + { + // Share cookies, not sockets, to preserve sessions between document loading and operations. + var cookies = new CookieContainer(); + this._pinnedClient = new HttpMessageInvoker(CreateHandler(cookies)); + this._unpinnedClient = new HttpMessageInvoker(CreateHandler(cookies)); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // Reused connections skip ConnectCallback, so pinned requests must never use the unchecked pool. + var client = request.Options.TryGetValue(ValidatedAddressesKey, out var addresses) && addresses.Length > 0 + ? this._pinnedClient + : this._unpinnedClient; + return client.SendAsync(request, cancellationToken); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this._pinnedClient.Dispose(); + this._unpinnedClient.Dispose(); + } + base.Dispose(disposing); + } + + private static SocketsHttpHandler CreateHandler(CookieContainer cookies) + { + return new SocketsHttpHandler + { + AllowAutoRedirect = false, + CookieContainer = cookies, + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + SslOptions = new SslClientAuthenticationOptions + { + CertificateRevocationCheckMode = X509RevocationMode.Online, + }, + ConnectCallback = ConnectAsync, + }; + } + } + + /// + /// Connects directly to an address validated for the request, preventing a second DNS lookup from selecting a different destination. + /// + /// + /// For example, https://api.example.com can connect to validated address 93.184.216.34 + /// while retaining the hostname for TLS and HTTP. Proxy destinations are left unchanged because the proxy owns DNS resolution. + /// Address attempts overlap after a short delay, keeping slow routes viable until another address connects. + /// + private static async ValueTask ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + var request = context.InitialRequestMessage; + var destination = context.DnsEndPoint; + + // Pin only direct requests with vetted DNS results; proxies and explicit validation bypasses keep their normal routing. + if (request.RequestUri is null || + !string.Equals(request.RequestUri.IdnHost, destination.Host, StringComparison.OrdinalIgnoreCase) || + request.RequestUri.Port != destination.Port || + !request.Options.TryGetValue(ValidatedAddressesKey, out var validatedAddresses) || + validatedAddresses.Length == 0) + { + return await ConnectSocketAsync(destination, cancellationToken).ConfigureAwait(false); + } + + return await ConnectToValidatedAddressesAsync(validatedAddresses, destination.Port, cancellationToken).ConfigureAwait(false); + } + + /// + /// Staggers connections to vetted addresses and disposes losing connections before returning the first success. + /// + /// + /// For example, IPv4 can start while IPv6 is pending, and either can still win. + /// Only TCP connections are raced; HTTP uses the selected connection afterward. + /// + private static async ValueTask ConnectToValidatedAddressesAsync(IPAddress[] addresses, int port, CancellationToken cancellationToken) + { + if (addresses.Length == 1) + { + // With no alternative address, racing would only add bookkeeping. + return await ConnectSocketAsync(new IPEndPoint(addresses[0], port), cancellationToken).ConfigureAwait(false); + } + + // A winner can cancel the losers without cancelling the caller; caller cancellation still stops the race. + using var raceCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Keep successes tracked: two addresses may connect before either is selected. + var attempts = new List>(addresses.Length); + var nextAttemptDelay = Task.CompletedTask; + int nextAddress = 0; + Stream? winningStream = null; + + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Start the next route while this one waits, or immediately if all earlier routes failed. + if (nextAddress < addresses.Length && (attempts.Count == 0 || nextAttemptDelay.IsCompleted)) + { + // AsTask lets both the race and cleanup safely await the same connection result. + attempts.Add(ConnectSocketAsync(new IPEndPoint(addresses[nextAddress++], port), raceCancellationSource.Token).AsTask()); + nextAttemptDelay = Task.Delay(s_connectionAttemptDelay, raceCancellationSource.Token); + } + + IEnumerable candidates = attempts; + // Include the delay only while another address can be started. + // Otherwise, an expired delay makes WhenAny return immediately instead of waiting for a connection. + if (nextAddress < addresses.Length) + { + candidates = candidates.Append(nextAttemptDelay); + } + + // Wait for either a connection attempt or the delay task to finish. + // WhenAny returns the finished task itself, so we can identify which kind of event occurred. + var completed = await Task.WhenAny(candidates).ConfigureAwait(false); + if (ReferenceEquals(completed, nextAttemptDelay)) + { + // The timer is a launch signal, not an address timeout. + // The next iteration starts B without cancelling A, or stops if the caller cancelled. + continue; + } + + // WhenAny reports completion, not success; await the connection below to observe its errors. + var completedAttempt = attempts.First(attempt => ReferenceEquals(attempt, completed)); + try + { + var stream = await completedAttempt.ConfigureAwait(false); + + // If cancellation wins this race, leave the stream tracked so cleanup closes it. + cancellationToken.ThrowIfCancellationRequested(); + + winningStream = stream; + + return stream; + } + // The failed task is still counted; require another attempt or untried address before ignoring its error. + catch (SocketException) when (attempts.Count > 1 || nextAddress < addresses.Length) + { + // E.g., a refused IPv4 route must not abort a still-pending IPv6 connection. + attempts.Remove(completedAttempt); + cancellationToken.ThrowIfCancellationRequested(); + } + } + } + finally + { + // Even return must finish cleanup before transferring ownership of the winning stream. + raceCancellationSource.Cancel(); + try + { + await DisposeLosingConnectionsAsync(attempts, winningStream).ConfigureAwait(false); + } + catch + { + // A cleanup failure prevents ownership transfer, even if a connection succeeded. + winningStream?.Dispose(); + throw; + } + } + } + + /// + /// Observes stopped attempts and closes every successful connection except the selected winner. + /// + private static async Task DisposeLosingConnectionsAsync(List> attempts, Stream? winningStream) + { + var completion = Task.WhenAll(attempts); + try + { + await completion.ConfigureAwait(false); + } + catch (SocketException) when (completion.Exception is { } failures) + { + // Refused routes are expected; unrelated failures must not turn into a successful request. + if (failures.InnerExceptions.Any(static exception => exception is not SocketException)) + { + throw failures; + } + } + catch (OperationCanceledException) when (completion.IsCanceled) + { + } + finally + { + // IPv4 and IPv6 can both connect before selection; cancellation alone does not close either stream. + foreach (var attempt in attempts) + { + if (attempt.IsCompletedSuccessfully) + { + var stream = await attempt.ConfigureAwait(false); + if (!ReferenceEquals(stream, winningStream)) + { + stream.Dispose(); + } + } + } + } + } + + /// + /// Opens a TCP stream to the exact endpoint. Passing an avoids another DNS lookup. + /// + private static async ValueTask ConnectSocketAsync(EndPoint endpoint, CancellationToken cancellationToken) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } +#endif +} diff --git a/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs b/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs index eb279481a190..abbe23d58ba8 100644 --- a/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs +++ b/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs @@ -15,7 +15,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.SemanticKernel.Http; namespace Microsoft.SemanticKernel.Plugins.OpenApi; @@ -195,10 +194,11 @@ internal static async Task CreateOpenApiPluginAsync( } /// - /// Returns the supplied HTTP client or the non-redirecting OpenAPI default. + /// Returns the supplied HTTP client or a non-redirecting OpenAPI client that pins + /// validated DNS addresses to connections. /// internal static HttpClient GetHttpClient(HttpClient? httpClient = null) => - httpClient ?? HttpClientProvider.GetNonRedirectingHttpClient(); + httpClient ?? OpenApiHttpClientProvider.CreateHttpClient(); /// /// Creates a plugin from an OpenAPI specification. diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index 390e94be7101..8c484c4723fa 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -194,13 +194,14 @@ public async Task RunAsync( { var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl); - await ServerUrlValidator.ValidateAsync(url, this._serverUrlValidationOptions, cancellationToken).ConfigureAwait(false); + var validatedAddresses = await ServerUrlValidator.ValidateAsync( + url, this._serverUrlValidationOptions, cancellationToken).ConfigureAwait(false); var headers = this._headersFactory?.Invoke(operation, arguments, options) ?? operation.BuildHeaders(arguments); var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments); - return await this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken).ConfigureAwait(false); + return await this.SendAsync(operation, url, headers, Payload, Content, options, validatedAddresses, cancellationToken).ConfigureAwait(false); } #region private @@ -214,6 +215,7 @@ public async Task RunAsync( /// HTTP request payload. /// HTTP request content. /// Options for REST API operation run. + /// DNS addresses validated for this request. /// The cancellation token. /// Response content and content type private async Task SendAsync( @@ -223,12 +225,17 @@ private async Task SendAsync( object? payload = null, HttpContent? requestContent = null, RestApiOperationRunOptions? options = null, + IPAddress[]? validatedAddresses = null, CancellationToken cancellationToken = default) { using var requestMessage = new HttpRequestMessage(operation.Method, url); #if NET requestMessage.Options.Set(OpenApiKernelFunctionContext.KernelFunctionContextKey, new OpenApiKernelFunctionContext(options?.Kernel, options?.KernelFunction, options?.KernelArguments)); + if (validatedAddresses is { Length: > 0 }) + { + requestMessage.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, validatedAddresses); + } #else requestMessage.Properties.Add(OpenApiKernelFunctionContext.KernelFunctionContextKey, new OpenApiKernelFunctionContext(options?.Kernel, options?.KernelFunction, options?.KernelArguments)); #endif diff --git a/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs b/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs index f77e47dd8462..ba1fc5d60bcb 100644 --- a/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs +++ b/dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs @@ -27,7 +27,8 @@ internal static class ServerUrlValidator /// Cancellation token for the asynchronous DNS resolution. /// Optional DNS resolver for testing. When , /// is used. - public static async Task ValidateAsync( + /// The validated DNS addresses to bind to the connection, or an empty array when DNS validation is bypassed. + public static async Task ValidateAsync( Uri url, RestApiOperationServerUrlValidationOptions? options, CancellationToken cancellationToken = default, @@ -44,7 +45,7 @@ public static async Task ValidateAsync( // 1. Explicit allow: a matching AllowedBaseUrls entry bypasses the implicit gates. if (TryMatchAllowedBaseUrl(url, options.AllowedBaseUrls)) { - return; + return []; } // If the caller set AllowedBaseUrls and the URL didn't match, reject before further @@ -68,10 +69,10 @@ public static async Task ValidateAsync( // 3. Implicit private-IP gate. if (options.AllowPrivateNetworkAccess) { - return; + return []; } - await PublicNetworkAddressValidator.ValidateAsync( + return await PublicNetworkAddressValidator.ValidateAsync( url, cancellationToken, dnsResolver, "To allow this URL, add it to " + $"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)} " + diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiHttpClientProviderTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiHttpClientProviderTests.cs new file mode 100644 index 000000000000..8246c6dd8577 --- /dev/null +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiHttpClientProviderTests.cs @@ -0,0 +1,449 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Plugins.OpenApi; +using Xunit; + +#pragma warning disable CA2000 // TcpListener is stopped in the finally block. + +namespace SemanticKernel.Functions.UnitTests.OpenApi; + +public sealed class OpenApiHttpClientProviderTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ItShouldConnectToValidatedAddressWithoutChangingHostAsync(bool useFallback) + { + // Arrange + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var listener = new TcpListener(IPAddress.Parse("127.0.0.2"), 0); + listener.Start(); + + try + { + var endpoint = Assert.IsType(listener.LocalEndpoint); + var serverTask = ServeOneRequestAsync(listener, cancellationTokenSource.Token); + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"http://localhost:{endpoint.Port}/resource"); + request.Options.Set( + OpenApiHttpClientProvider.ValidatedAddressesKey, + useFallback ? [IPAddress.Parse("127.0.0.3"), endpoint.Address] : [endpoint.Address]); + + // Act + using var response = await client.SendAsync(request, cancellationTokenSource.Token); + var (requestLine, hostHeader) = await serverTask; + + // Assert + response.EnsureSuccessStatusCode(); + Assert.Equal("GET /resource HTTP/1.1", requestLine); + Assert.Equal($"Host: localhost:{endpoint.Port}", hostHeader); + } + finally + { + listener.Stop(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ItShouldHandleAStalledValidatedAddressAsync(bool cancelRequest) + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var stalledListener = new TcpListener(IPAddress.Parse("127.0.0.3"), 0); + TcpListener? reachableListener = null; + var backlogConnections = new List(); + stalledListener.Start(1); + + try + { + var endpoint = Assert.IsType(stalledListener.LocalEndpoint); + reachableListener = new TcpListener(IPAddress.Parse("127.0.0.2"), endpoint.Port); + reachableListener.Start(); + + // A full listen backlog stalls new connection attempts instead of immediately refusing them. + await FillListenBacklogAsync(stalledListener, backlogConnections, cancellationTokenSource.Token); + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + client.Timeout = TimeSpan.FromMilliseconds(1500); + using var request = new HttpRequestMessage(HttpMethod.Get, $"http://localhost:{endpoint.Port}/resource"); + request.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, [IPAddress.Parse("127.0.0.3"), IPAddress.Parse("127.0.0.2")]); + + if (cancelRequest) + { + using var requestCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationTokenSource.Token); + requestCancellationSource.CancelAfter(TimeSpan.FromMilliseconds(100)); + + await Assert.ThrowsAnyAsync(() => client.SendAsync(request, requestCancellationSource.Token)); + Assert.False(reachableListener.Pending()); + } + else + { + var serverTask = ServeOneRequestAsync(reachableListener, cancellationTokenSource.Token); + using var response = await client.SendAsync(request, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); + var (requestLine, hostHeader) = await serverTask; + + Assert.Equal("GET /resource HTTP/1.1", requestLine); + Assert.Equal($"Host: localhost:{endpoint.Port}", hostHeader); + await DrainListenBacklogAsync(stalledListener, backlogConnections, cancellationTokenSource.Token); + Assert.False(stalledListener.Pending()); + } + } + finally + { + cancellationTokenSource.Cancel(); + stalledListener.Stop(); + reachableListener?.Stop(); + foreach (var socket in backlogConnections) + { + socket.Dispose(); + } + } + } + + [Fact] + public async Task ItShouldKeepAnEarlierValidatedAddressAttemptAliveAsync() + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var listener = new TcpListener(IPAddress.Parse("127.0.0.3"), 0); + var backlogConnections = new List(); + Task<(string? RequestLine, string? HostHeader)>? serverTask = null; + listener.Start(1); + + try + { + var endpoint = Assert.IsType(listener.LocalEndpoint); + await FillListenBacklogAsync(listener, backlogConnections, cancellationTokenSource.Token); + serverTask = ServeAfterReleasingBacklogAsync(listener, backlogConnections, cancellationTokenSource.Token); + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + client.Timeout = TimeSpan.FromSeconds(5); + using var request = new HttpRequestMessage(HttpMethod.Get, $"http://localhost:{endpoint.Port}/resource"); + request.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, [endpoint.Address, IPAddress.Parse("127.0.0.2")]); + + using var response = await client.SendAsync(request, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); + var (requestLine, hostHeader) = await serverTask; + + Assert.Equal("GET /resource HTTP/1.1", requestLine); + Assert.Equal($"Host: localhost:{endpoint.Port}", hostHeader); + } + finally + { + cancellationTokenSource.Cancel(); + listener.Stop(); + foreach (var socket in backlogConnections) + { + socket.Dispose(); + } + if (serverTask is not null) + { + try + { + await serverTask; + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + } + catch (SocketException) when (cancellationTokenSource.IsCancellationRequested) + { + } + } + } + } + + [Fact] + public async Task ItShouldPropagateFailureWhenAllValidatedAddressesFailAsync() + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.Parse("127.0.0.2"), 0)); + var endpoint = Assert.IsType(socket.LocalEndPoint); + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"http://localhost:{endpoint.Port}/resource"); + request.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, [endpoint.Address, IPAddress.Parse("127.0.0.3")]); + + var exception = await Assert.ThrowsAsync(() => client.SendAsync(request, cancellationTokenSource.Token)); + + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task ItShouldNotReuseAnUncheckedConnectionForAPinnedRequestAsync() + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var uncheckedListener = new TcpListener(IPAddress.Loopback, 0); + uncheckedListener.Start(); + var endpoint = Assert.IsType(uncheckedListener.LocalEndpoint); + var pinnedListener = new TcpListener(IPAddress.Parse("127.0.0.2"), endpoint.Port); + var releaseConnection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task<(string? RequestLine, string? HostHeader)>? uncheckedServerTask = null; + + try + { + pinnedListener.Start(); + uncheckedServerTask = ServeOneRequestAsync( + uncheckedListener, cancellationTokenSource.Token, releaseConnection.Task); + var pinnedServerTask = ServeOneRequestAsync(pinnedListener, cancellationTokenSource.Token); + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + var uri = new Uri($"http://localhost:{endpoint.Port}/resource"); + + // Keep the unchecked document-loading connection available in its pool. + using var documentResponse = await client.GetAsync(uri, cancellationTokenSource.Token); + documentResponse.EnsureSuccessStatusCode(); + Assert.False(uncheckedServerTask.IsCompleted); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, [IPAddress.Parse("127.0.0.2")]); + using var response = await client.SendAsync(request, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); + var (requestLine, hostHeader) = await pinnedServerTask; + + Assert.Equal("GET /resource HTTP/1.1", requestLine); + Assert.Equal($"Host: localhost:{endpoint.Port}", hostHeader); + } + finally + { + releaseConnection.TrySetResult(); + cancellationTokenSource.Cancel(); + uncheckedListener.Stop(); + pinnedListener.Stop(); + if (uncheckedServerTask is not null) + { + try + { + await uncheckedServerTask; + } + catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested) + { + } + catch (SocketException) when (cancellationTokenSource.IsCancellationRequested) + { + } + } + } + } + + [Fact] + public async Task ItShouldPreserveTlsHostnameAndRejectUntrustedCertificatesAsync() + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var key = RSA.Create(2048); + var certificateRequest = new CertificateRequest( + "CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = certificateRequest.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(5)); + var listener = new TcpListener(IPAddress.Parse("127.0.0.2"), 0); + listener.Start(); + + try + { + var endpoint = Assert.IsType(listener.LocalEndpoint); + var serverTask = CaptureTlsHostnameAsync(listener, certificate, cancellationTokenSource.Token); + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"https://localhost:{endpoint.Port}/resource"); + request.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, [endpoint.Address]); + + await Assert.ThrowsAsync(() => client.SendAsync(request, cancellationTokenSource.Token)); + Assert.Equal("localhost", await serverTask); + } + finally + { + listener.Stop(); + } + } + + [Fact] + public void ItShouldPreserveCallerSuppliedClients() + { + using var client = new HttpClient(); + + Assert.Same(client, OpenApiKernelPluginFactory.GetHttpClient(client)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ItShouldReuseTheSharedPoolAfterDisposingAClientAsync(bool pinAddress) + { + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var address = pinAddress ? IPAddress.Parse("127.0.0.2") : IPAddress.Loopback; + var listener = new TcpListener(address, 0); + listener.Start(); + + try + { + var endpoint = Assert.IsType(listener.LocalEndpoint); + var serverTask = ServeTwoRequestsOnOneConnectionAsync(listener, cancellationTokenSource.Token); + var uri = new Uri($"http://localhost:{endpoint.Port}/resource"); + + // Disposing a lightweight client must not close the shared pool's connection. + for (int i = 0; i < 2; i++) + { + using var client = OpenApiHttpClientProvider.CreateHttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (pinAddress) + { + request.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, [address]); + } + + using var response = await client.SendAsync(request, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); + } + + await serverTask; + } + finally + { + cancellationTokenSource.Cancel(); + listener.Stop(); + } + } + + private static async Task FillListenBacklogAsync(TcpListener listener, List connections, CancellationToken cancellationToken) + { + for (int i = 0; i < 10; i++) + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + connections.Add(socket); + using var attemptCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + attemptCancellationSource.CancelAfter(TimeSpan.FromMilliseconds(500)); + + try + { + await socket.ConnectAsync(listener.LocalEndpoint, attemptCancellationSource.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // On Linux, a cancelled probe can still connect later and be mistaken for the HTTP client. + socket.Dispose(); + connections.Remove(socket); + return; + } + } + + Assert.Fail("The loopback listener's backlog did not fill."); + } + + private static async Task DrainListenBacklogAsync(TcpListener listener, List connections, CancellationToken cancellationToken) + { + foreach (var socket in connections) + { + if (socket.Connected) + { + using var connection = await listener.AcceptTcpClientAsync(cancellationToken); + } + } + } + + private static async Task<(string? RequestLine, string? HostHeader)> ServeAfterReleasingBacklogAsync(TcpListener listener, List connections, CancellationToken cancellationToken) + { + // Release this route late to verify that starting another attempt does not cancel it. + await Task.Delay(TimeSpan.FromMilliseconds(1250), cancellationToken); + await DrainListenBacklogAsync(listener, connections, cancellationToken); + return await ServeOneRequestAsync(listener, cancellationToken); + } + + private static async Task ServeTwoRequestsOnOneConnectionAsync(TcpListener listener, CancellationToken cancellationToken) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + + for (int i = 0; i < 2; i++) + { + Assert.Equal("GET /resource HTTP/1.1", await reader.ReadLineAsync(cancellationToken)); + string? header; + do + { + header = await reader.ReadLineAsync(cancellationToken); + } + while (!string.IsNullOrEmpty(header)); + + var connectionHeader = i == 0 ? "keep-alive" : "close"; + var response = $"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: {connectionHeader}\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken); + } + } + + private static async Task<(string? RequestLine, string? HostHeader)> ServeOneRequestAsync( + TcpListener listener, + CancellationToken cancellationToken, + Task? releaseConnection = null) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + await using var stream = client.GetStream(); + using var reader = new StreamReader( + stream, + Encoding.ASCII, + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: true); + + var requestLine = await reader.ReadLineAsync(cancellationToken); + string? hostHeader = null; + string? header; + do + { + header = await reader.ReadLineAsync(cancellationToken); + if (header?.StartsWith("Host:", StringComparison.OrdinalIgnoreCase) == true) + { + hostHeader = header; + } + } + while (!string.IsNullOrEmpty(header)); + + var connectionHeader = releaseConnection is null ? "close" : "keep-alive"; + var response = $"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: {connectionHeader}\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken); + if (releaseConnection is not null) + { + await releaseConnection.WaitAsync(cancellationToken); + } + return (requestLine, hostHeader); + } + + private static async Task CaptureTlsHostnameAsync( + TcpListener listener, + X509Certificate2 certificate, + CancellationToken cancellationToken) + { + using var client = await listener.AcceptTcpClientAsync(cancellationToken); + await using var stream = new SslStream(client.GetStream()); + string? tlsHostname = null; + try + { + await stream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificateSelectionCallback = (_, hostname) => + { + tlsHostname = hostname; + return certificate; + } + }, cancellationToken); + } + catch (AuthenticationException) + { + // The client must reject the self-signed certificate after sending its original SNI hostname. + } + catch (IOException) + { + // Some TLS providers close the stream when rejecting the certificate. + } + return tlsHostname; + } +} diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/ServerUrlValidatorTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/ServerUrlValidatorTests.cs index f31826403aa9..727c714f1a4e 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/ServerUrlValidatorTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/ServerUrlValidatorTests.cs @@ -148,7 +148,9 @@ public async Task ItShouldPreserveExplicitHostnameAllowlistDnsBypassAsync() Task UnexpectedResolver(string host, CancellationToken token) => throw new InvalidOperationException("An explicitly trusted URL should not be resolved."); - await ServerUrlValidator.ValidateAsync(url, options, dnsResolver: UnexpectedResolver); + var addresses = await ServerUrlValidator.ValidateAsync(url, options, dnsResolver: UnexpectedResolver); + + Assert.Empty(addresses); } [Fact] @@ -166,7 +168,9 @@ public async Task ItShouldBypassPrivateGateWhenAllowPrivateNetworkAccessTrueAsyn { var options = new RestApiOperationServerUrlValidationOptions { AllowPrivateNetworkAccess = true }; - await ServerUrlValidator.ValidateAsync(new Uri(url), options); + var addresses = await ServerUrlValidator.ValidateAsync(new Uri(url), options); + + Assert.Empty(addresses); } [Theory] @@ -221,8 +225,9 @@ public async Task ItShouldAllowHostnameResolvingToPublicIpAsync() Task FakeResolver(string _, CancellationToken _1) => Task.FromResult(new[] { IPAddress.Parse("93.184.216.34") }); - // Should not throw. - await ServerUrlValidator.ValidateAsync(url, options: null, dnsResolver: FakeResolver); + var addresses = await ServerUrlValidator.ValidateAsync(url, options: null, dnsResolver: FakeResolver); + + Assert.Equal([IPAddress.Parse("93.184.216.34")], addresses); } [Fact] diff --git a/dotnet/src/InternalUtilities/src/Http/PublicNetworkAddressValidator.cs b/dotnet/src/InternalUtilities/src/Http/PublicNetworkAddressValidator.cs index a9c923336afb..bb760be6e7e0 100644 --- a/dotnet/src/InternalUtilities/src/Http/PublicNetworkAddressValidator.cs +++ b/dotnet/src/InternalUtilities/src/Http/PublicNetworkAddressValidator.cs @@ -11,15 +11,17 @@ namespace Microsoft.SemanticKernel.Http; /// /// Resolves and rejects non-public request destinations before an HTTP request is sent. -/// This check does not pin the validated addresses to the subsequent HTTP connection. +/// The caller is responsible for binding the returned addresses to the subsequent HTTP connection. /// [ExcludeFromCodeCoverage] internal static class PublicNetworkAddressValidator { /// - /// Validates all addresses of a request host, failing closed on DNS errors. + /// Validates all addresses of a request host, failing closed on DNS errors, and returns + /// the validated DNS addresses in resolver order. /// - public static async Task ValidateAsync( + /// The validated DNS addresses, or an empty array when the URL contains a literal IP address. + public static async Task ValidateAsync( Uri url, CancellationToken cancellationToken = default, Func>? dnsResolver = null, @@ -39,7 +41,7 @@ public static async Task ValidateAsync( } EnsurePublicAddress(url, ip, configurationHint); - return; + return []; } // Resolve the ASCII host used by HttpClient. DnsSafeHost can contain Unicode IDN labels. @@ -86,6 +88,8 @@ public static async Task ValidateAsync( { EnsurePublicAddress(url, address, configurationHint); } + + return addresses; } ///