Skip to content

Commit 11f0723

Browse files
.Net: Improve OpenAPI HTTP connection handling (#14570)
### Motivation and Context <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> Improve consistency of the built-in OpenAPI HTTP client while preserving request identity and existing customization points. ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> Refine connection handling and internal validation-result propagation. Preserve caller-supplied clients and existing redirect behavior. Add regression coverage for connection selection, pooling, and HTTPS behavior. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent cc8a15f commit 11f0723

8 files changed

Lines changed: 768 additions & 17 deletions

File tree

‎dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ public class OpenApiFunctionExecutionParameters
2020
/// <remarks>
2121
/// The default client does not follow redirects so that URL validation cannot be bypassed.
2222
/// Custom clients should also be configured with <c>AllowAutoRedirect = false</c>.
23+
/// In the .NET 8 and .NET 10 builds, the default client pins direct operation connections
24+
/// to validated DNS addresses. Custom clients own their connection-time DNS validation;
25+
/// setting <c>AllowAutoRedirect = false</c> alone does not prevent DNS rebinding.
26+
/// Proxy-mediated requests and the .NET Standard 2.0 build are not pinned.
2327
/// </remarks>
2428
public HttpClient? HttpClient { get; set; }
2529

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
#if NET
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Net;
9+
#endif
10+
using System.Net.Http;
11+
#if NET
12+
using System.Net.Security;
13+
using System.Net.Sockets;
14+
using System.Security.Cryptography.X509Certificates;
15+
using System.Threading;
16+
using System.Threading.Tasks;
17+
#else
18+
using Microsoft.SemanticKernel.Http;
19+
#endif
20+
21+
#pragma warning disable CA2000 // Ownership is transferred to the shared handler or NetworkStream.
22+
23+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
24+
25+
/// <summary>
26+
/// Creates the built-in OpenAPI HTTP client and binds validated DNS addresses to its connections.
27+
/// </summary>
28+
internal static class OpenApiHttpClientProvider
29+
{
30+
#if NET
31+
internal static readonly HttpRequestOptionsKey<IPAddress[]> ValidatedAddressesKey = new("OpenApiValidatedAddresses");
32+
33+
// RFC 8305's 250 ms delay starts another route (e.g., IPv4) without timing out the first (e.g., IPv6).
34+
private static readonly TimeSpan s_connectionAttemptDelay = TimeSpan.FromMilliseconds(250);
35+
#endif
36+
37+
/// <summary>
38+
/// Creates a non-redirecting client backed by a shared handler with separate pinned and unchecked connection pools.
39+
/// </summary>
40+
public static HttpClient CreateHttpClient()
41+
{
42+
#if NET
43+
return new HttpClient(ValidatedAddressHandler.Instance, disposeHandler: false);
44+
#else
45+
return HttpClientProvider.GetNonRedirectingHttpClient();
46+
#endif
47+
}
48+
49+
#if NET
50+
/// <summary>
51+
/// Keeps pinned operations from reusing unchecked connections opened for document loading or explicit policy bypasses.
52+
/// </summary>
53+
private sealed class ValidatedAddressHandler : HttpMessageHandler
54+
{
55+
// Plugins have no disposal contract; keep pool ownership at assembly scope rather than allocating pools per plugin.
56+
public static ValidatedAddressHandler Instance { get; } = new();
57+
58+
private readonly HttpMessageInvoker _pinnedClient;
59+
private readonly HttpMessageInvoker _unpinnedClient;
60+
61+
private ValidatedAddressHandler()
62+
{
63+
// Share cookies, not sockets, to preserve sessions between document loading and operations.
64+
var cookies = new CookieContainer();
65+
this._pinnedClient = new HttpMessageInvoker(CreateHandler(cookies));
66+
this._unpinnedClient = new HttpMessageInvoker(CreateHandler(cookies));
67+
}
68+
69+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
70+
{
71+
// Reused connections skip ConnectCallback, so pinned requests must never use the unchecked pool.
72+
var client = request.Options.TryGetValue(ValidatedAddressesKey, out var addresses) && addresses.Length > 0
73+
? this._pinnedClient
74+
: this._unpinnedClient;
75+
return client.SendAsync(request, cancellationToken);
76+
}
77+
78+
protected override void Dispose(bool disposing)
79+
{
80+
if (disposing)
81+
{
82+
this._pinnedClient.Dispose();
83+
this._unpinnedClient.Dispose();
84+
}
85+
base.Dispose(disposing);
86+
}
87+
88+
private static SocketsHttpHandler CreateHandler(CookieContainer cookies)
89+
{
90+
return new SocketsHttpHandler
91+
{
92+
AllowAutoRedirect = false,
93+
CookieContainer = cookies,
94+
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
95+
SslOptions = new SslClientAuthenticationOptions
96+
{
97+
CertificateRevocationCheckMode = X509RevocationMode.Online,
98+
},
99+
ConnectCallback = ConnectAsync,
100+
};
101+
}
102+
}
103+
104+
/// <summary>
105+
/// Connects directly to an address validated for the request, preventing a second DNS lookup from selecting a different destination.
106+
/// </summary>
107+
/// <remarks>
108+
/// For example, <c>https://api.example.com</c> can connect to validated address <c>93.184.216.34</c>
109+
/// while retaining the hostname for TLS and HTTP. Proxy destinations are left unchanged because the proxy owns DNS resolution.
110+
/// Address attempts overlap after a short delay, keeping slow routes viable until another address connects.
111+
/// </remarks>
112+
private static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
113+
{
114+
var request = context.InitialRequestMessage;
115+
var destination = context.DnsEndPoint;
116+
117+
// Pin only direct requests with vetted DNS results; proxies and explicit validation bypasses keep their normal routing.
118+
if (request.RequestUri is null ||
119+
!string.Equals(request.RequestUri.IdnHost, destination.Host, StringComparison.OrdinalIgnoreCase) ||
120+
request.RequestUri.Port != destination.Port ||
121+
!request.Options.TryGetValue(ValidatedAddressesKey, out var validatedAddresses) ||
122+
validatedAddresses.Length == 0)
123+
{
124+
return await ConnectSocketAsync(destination, cancellationToken).ConfigureAwait(false);
125+
}
126+
127+
return await ConnectToValidatedAddressesAsync(validatedAddresses, destination.Port, cancellationToken).ConfigureAwait(false);
128+
}
129+
130+
/// <summary>
131+
/// Staggers connections to vetted addresses and disposes losing connections before returning the first success.
132+
/// </summary>
133+
/// <remarks>
134+
/// For example, IPv4 can start while IPv6 is pending, and either can still win.
135+
/// Only TCP connections are raced; HTTP uses the selected connection afterward.
136+
/// </remarks>
137+
private static async ValueTask<Stream> ConnectToValidatedAddressesAsync(IPAddress[] addresses, int port, CancellationToken cancellationToken)
138+
{
139+
if (addresses.Length == 1)
140+
{
141+
// With no alternative address, racing would only add bookkeeping.
142+
return await ConnectSocketAsync(new IPEndPoint(addresses[0], port), cancellationToken).ConfigureAwait(false);
143+
}
144+
145+
// A winner can cancel the losers without cancelling the caller; caller cancellation still stops the race.
146+
using var raceCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
147+
148+
// Keep successes tracked: two addresses may connect before either is selected.
149+
var attempts = new List<Task<Stream>>(addresses.Length);
150+
var nextAttemptDelay = Task.CompletedTask;
151+
int nextAddress = 0;
152+
Stream? winningStream = null;
153+
154+
try
155+
{
156+
while (true)
157+
{
158+
cancellationToken.ThrowIfCancellationRequested();
159+
160+
// Start the next route while this one waits, or immediately if all earlier routes failed.
161+
if (nextAddress < addresses.Length && (attempts.Count == 0 || nextAttemptDelay.IsCompleted))
162+
{
163+
// AsTask lets both the race and cleanup safely await the same connection result.
164+
attempts.Add(ConnectSocketAsync(new IPEndPoint(addresses[nextAddress++], port), raceCancellationSource.Token).AsTask());
165+
nextAttemptDelay = Task.Delay(s_connectionAttemptDelay, raceCancellationSource.Token);
166+
}
167+
168+
IEnumerable<Task> candidates = attempts;
169+
// Include the delay only while another address can be started.
170+
// Otherwise, an expired delay makes WhenAny return immediately instead of waiting for a connection.
171+
if (nextAddress < addresses.Length)
172+
{
173+
candidates = candidates.Append(nextAttemptDelay);
174+
}
175+
176+
// Wait for either a connection attempt or the delay task to finish.
177+
// WhenAny returns the finished task itself, so we can identify which kind of event occurred.
178+
var completed = await Task.WhenAny(candidates).ConfigureAwait(false);
179+
if (ReferenceEquals(completed, nextAttemptDelay))
180+
{
181+
// The timer is a launch signal, not an address timeout.
182+
// The next iteration starts B without cancelling A, or stops if the caller cancelled.
183+
continue;
184+
}
185+
186+
// WhenAny reports completion, not success; await the connection below to observe its errors.
187+
var completedAttempt = attempts.First(attempt => ReferenceEquals(attempt, completed));
188+
try
189+
{
190+
var stream = await completedAttempt.ConfigureAwait(false);
191+
192+
// If cancellation wins this race, leave the stream tracked so cleanup closes it.
193+
cancellationToken.ThrowIfCancellationRequested();
194+
195+
winningStream = stream;
196+
197+
return stream;
198+
}
199+
// The failed task is still counted; require another attempt or untried address before ignoring its error.
200+
catch (SocketException) when (attempts.Count > 1 || nextAddress < addresses.Length)
201+
{
202+
// E.g., a refused IPv4 route must not abort a still-pending IPv6 connection.
203+
attempts.Remove(completedAttempt);
204+
cancellationToken.ThrowIfCancellationRequested();
205+
}
206+
}
207+
}
208+
finally
209+
{
210+
// Even return must finish cleanup before transferring ownership of the winning stream.
211+
raceCancellationSource.Cancel();
212+
try
213+
{
214+
await DisposeLosingConnectionsAsync(attempts, winningStream).ConfigureAwait(false);
215+
}
216+
catch
217+
{
218+
// A cleanup failure prevents ownership transfer, even if a connection succeeded.
219+
winningStream?.Dispose();
220+
throw;
221+
}
222+
}
223+
}
224+
225+
/// <summary>
226+
/// Observes stopped attempts and closes every successful connection except the selected winner.
227+
/// </summary>
228+
private static async Task DisposeLosingConnectionsAsync(List<Task<Stream>> attempts, Stream? winningStream)
229+
{
230+
var completion = Task.WhenAll(attempts);
231+
try
232+
{
233+
await completion.ConfigureAwait(false);
234+
}
235+
catch (SocketException) when (completion.Exception is { } failures)
236+
{
237+
// Refused routes are expected; unrelated failures must not turn into a successful request.
238+
if (failures.InnerExceptions.Any(static exception => exception is not SocketException))
239+
{
240+
throw failures;
241+
}
242+
}
243+
catch (OperationCanceledException) when (completion.IsCanceled)
244+
{
245+
}
246+
finally
247+
{
248+
// IPv4 and IPv6 can both connect before selection; cancellation alone does not close either stream.
249+
foreach (var attempt in attempts)
250+
{
251+
if (attempt.IsCompletedSuccessfully)
252+
{
253+
var stream = await attempt.ConfigureAwait(false);
254+
if (!ReferenceEquals(stream, winningStream))
255+
{
256+
stream.Dispose();
257+
}
258+
}
259+
}
260+
}
261+
}
262+
263+
/// <summary>
264+
/// Opens a TCP stream to the exact endpoint. Passing an <see cref="IPEndPoint"/> avoids another DNS lookup.
265+
/// </summary>
266+
private static async ValueTask<Stream> ConnectSocketAsync(EndPoint endpoint, CancellationToken cancellationToken)
267+
{
268+
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
269+
try
270+
{
271+
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
272+
return new NetworkStream(socket, ownsSocket: true);
273+
}
274+
catch
275+
{
276+
socket.Dispose();
277+
throw;
278+
}
279+
}
280+
#endif
281+
}

‎dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using System.Threading.Tasks;
1616
using Microsoft.Extensions.Logging;
1717
using Microsoft.Extensions.Logging.Abstractions;
18-
using Microsoft.SemanticKernel.Http;
1918

2019
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
2120

@@ -195,10 +194,11 @@ internal static async Task<KernelPlugin> CreateOpenApiPluginAsync(
195194
}
196195

197196
/// <summary>
198-
/// Returns the supplied HTTP client or the non-redirecting OpenAPI default.
197+
/// Returns the supplied HTTP client or a non-redirecting OpenAPI client that pins
198+
/// validated DNS addresses to connections.
199199
/// </summary>
200200
internal static HttpClient GetHttpClient(HttpClient? httpClient = null) =>
201-
httpClient ?? HttpClientProvider.GetNonRedirectingHttpClient();
201+
httpClient ?? OpenApiHttpClientProvider.CreateHttpClient();
202202

203203
/// <summary>
204204
/// Creates a plugin from an OpenAPI specification.

‎dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,14 @@ public async Task<RestApiOperationResponse> RunAsync(
194194
{
195195
var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl);
196196

197-
await ServerUrlValidator.ValidateAsync(url, this._serverUrlValidationOptions, cancellationToken).ConfigureAwait(false);
197+
var validatedAddresses = await ServerUrlValidator.ValidateAsync(
198+
url, this._serverUrlValidationOptions, cancellationToken).ConfigureAwait(false);
198199

199200
var headers = this._headersFactory?.Invoke(operation, arguments, options) ?? operation.BuildHeaders(arguments);
200201

201202
var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments);
202203

203-
return await this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken).ConfigureAwait(false);
204+
return await this.SendAsync(operation, url, headers, Payload, Content, options, validatedAddresses, cancellationToken).ConfigureAwait(false);
204205
}
205206

206207
#region private
@@ -214,6 +215,7 @@ public async Task<RestApiOperationResponse> RunAsync(
214215
/// <param name="payload">HTTP request payload.</param>
215216
/// <param name="requestContent">HTTP request content.</param>
216217
/// <param name="options">Options for REST API operation run.</param>
218+
/// <param name="validatedAddresses">DNS addresses validated for this request.</param>
217219
/// <param name="cancellationToken">The cancellation token.</param>
218220
/// <returns>Response content and content type</returns>
219221
private async Task<RestApiOperationResponse> SendAsync(
@@ -223,12 +225,17 @@ private async Task<RestApiOperationResponse> SendAsync(
223225
object? payload = null,
224226
HttpContent? requestContent = null,
225227
RestApiOperationRunOptions? options = null,
228+
IPAddress[]? validatedAddresses = null,
226229
CancellationToken cancellationToken = default)
227230
{
228231
using var requestMessage = new HttpRequestMessage(operation.Method, url);
229232

230233
#if NET
231234
requestMessage.Options.Set(OpenApiKernelFunctionContext.KernelFunctionContextKey, new OpenApiKernelFunctionContext(options?.Kernel, options?.KernelFunction, options?.KernelArguments));
235+
if (validatedAddresses is { Length: > 0 })
236+
{
237+
requestMessage.Options.Set(OpenApiHttpClientProvider.ValidatedAddressesKey, validatedAddresses);
238+
}
232239
#else
233240
requestMessage.Properties.Add(OpenApiKernelFunctionContext.KernelFunctionContextKey, new OpenApiKernelFunctionContext(options?.Kernel, options?.KernelFunction, options?.KernelArguments));
234241
#endif

‎dotnet/src/Functions/Functions.OpenApi/ServerUrlValidator.cs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ internal static class ServerUrlValidator
2727
/// <param name="cancellationToken">Cancellation token for the asynchronous DNS resolution.</param>
2828
/// <param name="dnsResolver">Optional DNS resolver for testing. When <see langword="null"/>,
2929
/// <see cref="Dns.GetHostAddressesAsync(string)"/> is used.</param>
30-
public static async Task ValidateAsync(
30+
/// <returns>The validated DNS addresses to bind to the connection, or an empty array when DNS validation is bypassed.</returns>
31+
public static async Task<IPAddress[]> ValidateAsync(
3132
Uri url,
3233
RestApiOperationServerUrlValidationOptions? options,
3334
CancellationToken cancellationToken = default,
@@ -44,7 +45,7 @@ public static async Task ValidateAsync(
4445
// 1. Explicit allow: a matching AllowedBaseUrls entry bypasses the implicit gates.
4546
if (TryMatchAllowedBaseUrl(url, options.AllowedBaseUrls))
4647
{
47-
return;
48+
return [];
4849
}
4950

5051
// If the caller set AllowedBaseUrls and the URL didn't match, reject before further
@@ -68,10 +69,10 @@ public static async Task ValidateAsync(
6869
// 3. Implicit private-IP gate.
6970
if (options.AllowPrivateNetworkAccess)
7071
{
71-
return;
72+
return [];
7273
}
7374

74-
await PublicNetworkAddressValidator.ValidateAsync(
75+
return await PublicNetworkAddressValidator.ValidateAsync(
7576
url, cancellationToken, dnsResolver,
7677
"To allow this URL, add it to " +
7778
$"{nameof(RestApiOperationServerUrlValidationOptions)}.{nameof(RestApiOperationServerUrlValidationOptions.AllowedBaseUrls)} " +

0 commit comments

Comments
 (0)