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;
}
///