From 69edc87404960d3ef69d7dedd9e90a75e81419e9 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:28:19 -0400 Subject: [PATCH 01/21] fix: do not forward headers on redirect Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 188 ++++++++++--- .../DefaultHttpRequestHandlerTests.cs | 252 ++++++++++++++++++ 2 files changed, 409 insertions(+), 31 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 606a716c207..9399e5101f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Threading; @@ -24,9 +26,17 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// The handler applies the per-request using a linked /// so it does not mutate on shared instances. /// +/// +/// Redirects are handled by this handler so request credentials are not forwarded to a different origin. The +/// internally owned client disables automatic redirects. Supplied clients should also disable automatic +/// redirects; clients with credential-bearing default headers are rejected because their redirect behavior is +/// opaque to this handler. +/// /// public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable { + private const int MaxAutomaticRedirections = 50; + private readonly Func>? _httpClientProvider; private readonly Lazy _ownedHttpClient; @@ -80,7 +90,7 @@ public DefaultHttpRequestHandler(HttpClient httpClient) public DefaultHttpRequestHandler(Func>? httpClientProvider) { this._httpClientProvider = httpClientProvider; - this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); + this._ownedHttpClient = new Lazy(CreateOwnedHttpClient, LazyThreadSafetyMode.ExecutionAndPublication); } private static Func> CreateSingleClientProvider(HttpClient httpClient) @@ -111,50 +121,70 @@ public async Task SendAsync(HttpRequestInfo request, Cancella throw new ArgumentException("Request method must be provided.", nameof(request)); } - HttpClient? providedClient = null; - if (this._httpClientProvider is not null) + HttpRequestInfo currentRequest = request; + Uri currentUri = CreateAbsoluteUri(ResolveRequestUri(request)); + + for (int redirectCount = 0; redirectCount <= MaxAutomaticRedirections; redirectCount++) { - providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false); - } + HttpClient? providedClient = null; + if (this._httpClientProvider is not null) + { + providedClient = await this._httpClientProvider(currentRequest, cancellationToken).ConfigureAwait(false); + } + + if (providedClient is not null) + { + ThrowIfUnsafeProvidedClientHeaders(providedClient, currentRequest); + } - HttpClient client = providedClient ?? this._ownedHttpClient.Value; + HttpClient client = providedClient ?? this._ownedHttpClient.Value; - using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request); + using HttpRequestMessage httpRequest = BuildHttpRequestMessage(currentRequest); - using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) - : null; + using CancellationTokenSource? timeoutCts = currentRequest.Timeout is { } timeout && timeout > TimeSpan.Zero + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; - timeoutCts?.CancelAfter(request.Timeout!.Value); + timeoutCts?.CancelAfter(currentRequest.Timeout!.Value); - CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; + CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; - using HttpResponseMessage httpResponse = await client - .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) - .ConfigureAwait(false); + using HttpResponseMessage httpResponse = await client + .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) + .ConfigureAwait(false); + + if (TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri)) + { + currentRequest = redirectRequest; + currentUri = redirectUri; + continue; + } - string? body = httpResponse.Content is null - ? null + string? body = httpResponse.Content is null + ? null #if NET - : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); + : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); #else - : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); #endif - Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); - AppendHeaders(headers, httpResponse.Headers); - if (httpResponse.Content is not null) - { - AppendHeaders(headers, httpResponse.Content.Headers); + Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); + AppendHeaders(headers, httpResponse.Headers); + if (httpResponse.Content is not null) + { + AppendHeaders(headers, httpResponse.Content.Headers); + } + + return new HttpRequestResult + { + StatusCode = (int)httpResponse.StatusCode, + IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, + Body = body, + Headers = headers, + }; } - return new HttpRequestResult - { - StatusCode = (int)httpResponse.StatusCode, - IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, - Body = body, - Headers = headers, - }; + throw new HttpRequestException($"The maximum number of HTTP redirects ({MaxAutomaticRedirections}) was exceeded."); } /// @@ -213,6 +243,102 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques return httpRequest; } + private static HttpClient CreateOwnedHttpClient() + { + HttpClientHandler handler = new() + { + AllowAutoRedirect = false, + UseCookies = false, + CheckCertificateRevocationList = true + }; + + return new HttpClient(handler); + } + + private static Uri CreateAbsoluteUri(string requestUri) + { + if (!Uri.TryCreate(requestUri, UriKind.Absolute, out Uri? uri)) + { + throw new ArgumentException("Request URL must be an absolute URL."); + } + + return uri; + } + + private static bool TryCreateRedirectRequest( + HttpResponseMessage response, + HttpRequestInfo currentRequest, + Uri currentUri, + [NotNullWhen(true)] out HttpRequestInfo? redirectRequest, + [NotNullWhen(true)] out Uri? redirectUri) + { + redirectRequest = null; + redirectUri = null; + + if (!IsRedirectStatusCode(response.StatusCode) || response.Headers.Location is null) + { + return false; + } + + redirectUri = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(currentUri, response.Headers.Location); + + bool rewriteToGet = ShouldRewriteRedirectMethodToGet(response.StatusCode, currentRequest.Method); + + redirectRequest = new HttpRequestInfo + { + Method = rewriteToGet ? "GET" : currentRequest.Method, + Url = redirectUri.ToString(), + Body = rewriteToGet ? null : currentRequest.Body, + BodyContentType = rewriteToGet ? null : currentRequest.BodyContentType, + Timeout = currentRequest.Timeout, + ConnectionName = currentRequest.ConnectionName, + }; + + return true; + } + + private static bool IsRedirectStatusCode(HttpStatusCode statusCode) + { + int code = (int)statusCode; + return code is 301 or 302 or 303 or 307 or 308; + } + + private static bool ShouldRewriteRedirectMethodToGet(HttpStatusCode statusCode, string method) + { + string normalized = method.Trim().ToUpperInvariant(); + int code = (int)statusCode; + return code == 303 || ((code == 301 || code == 302) && string.Equals(normalized, "POST", StringComparison.Ordinal)); + } + + private static void ThrowIfUnsafeProvidedClientHeaders(HttpClient providedClient, HttpRequestInfo request) + { + if (providedClient.DefaultRequestHeaders.Any(header => IsSensitiveHeaderName(header.Key))) + { + throw new InvalidOperationException( + "DefaultHttpRequestHandler cannot safely use a provided HttpClient with credential-bearing DefaultRequestHeaders because the client may forward them during automatic redirects. Configure credentials with an origin-pinning handler that disables automatic redirects."); + } + + if (request.Headers?.Keys.Any(IsCustomCredentialHeaderName) == true) + { + throw new InvalidOperationException( + "DefaultHttpRequestHandler cannot safely send credential-bearing request headers through a provided HttpClient because the client may forward them during automatic redirects. Use the handler-owned client or configure an origin-pinning handler that disables automatic redirects."); + } + } + + private static bool IsSensitiveHeaderName(string headerName) => + string.Equals(headerName, "Authorization", StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, "Proxy-Authorization", StringComparison.OrdinalIgnoreCase) || + string.Equals(headerName, "Cookie", StringComparison.OrdinalIgnoreCase) || + IsCustomCredentialHeaderName(headerName); + + private static bool IsCustomCredentialHeaderName(string headerName) => + headerName.Contains("Api-Key", StringComparison.OrdinalIgnoreCase) || + headerName.Contains("Token", StringComparison.OrdinalIgnoreCase) || + headerName.Contains("Secret", StringComparison.OrdinalIgnoreCase) || + headerName.Contains("Credential", StringComparison.OrdinalIgnoreCase); + private static HttpMethod ResolveMethod(string method) { string normalized = method.Trim().ToUpperInvariant(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 6970bc336c2..e1267902394 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -385,6 +386,173 @@ public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() Assert.Equal(1, providerCallCount); } + [Fact] + public async Task SendAsyncOwnedClientDoesNotForwardRequestHeadersToRedirectedEndpointAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + const string HeaderValue = "request-header-value"; + List forwardedHeaderValues = []; + await using LoopbackServer secondaryEndpoint = await LoopbackServer.StartAsync(async (context, ct) => + { + forwardedHeaderValues.AddRange(context.Request.Headers.GetValues("X-Request-Token") ?? []); + await WriteResponseAsync(context, HttpStatusCode.OK, "redirect-ok", ct); + }, cancellationToken); + + await using LoopbackServer primaryEndpoint = await LoopbackServer.StartAsync((context, _) => + { + context.Response.StatusCode = (int)HttpStatusCode.TemporaryRedirect; + context.Response.RedirectLocation = new Uri(secondaryEndpoint.BaseUri, "next").ToString(); + context.Response.Close(); + return Task.CompletedTask; + }, cancellationToken); + + await using DefaultHttpRequestHandler handler = new(); + HttpRequestInfo request = new() + { + Method = "GET", + Url = new Uri(primaryEndpoint.BaseUri, "redirect").ToString(), + Headers = new Dictionary + { + ["X-Request-Token"] = HeaderValue, + }, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + await Task.WhenAll(primaryEndpoint.Completion, secondaryEndpoint.Completion); + + // Assert + Assert.Equal(200, result.StatusCode); + Assert.Equal("redirect-ok", result.Body); + Assert.Empty(forwardedHeaderValues); + } + + [Fact] + public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List requestsWithHeader = []; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); + if (requestsWithHeader.Count == 1) + { + HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); + response.Headers.Location = new Uri("https://api.example.test/next"); + return Task.FromResult(response); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }); + }); +#pragma warning restore CA2025 + + using HttpClient client = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(client); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Headers = new Dictionary + { + ["X-Trace-Id"] = "trace-1", + }, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal([true, false], requestsWithHeader); + } + + [Fact] + public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpClient providerClient = new(); + providerClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); + + int providerCallCount = 0; +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((_, _) => + { + providerCallCount++; + return Task.FromResult(providerClient); + }); +#pragma warning restore CA2025 + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + InvalidOperationException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("DefaultRequestHeaders", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, providerCallCount); + } + + [Fact] + public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; +#pragma warning disable CA2025 + TestHttpMessageHandler primaryMessageHandler = new((req, _) => + { + HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); + response.Headers.Location = new Uri("https://secondary.example.test/next"); + return Task.FromResult(response); + }); + TestHttpMessageHandler secondaryMessageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + })); +#pragma warning restore CA2025 + + using HttpClient primaryClient = new(primaryMessageHandler); + using HttpClient secondaryClient = new(secondaryMessageHandler); + List providerUrls = []; +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((info, _) => + { + providerUrls.Add(info.Url); + HttpClient client = info.Url.StartsWith("https://api.example.test/", StringComparison.Ordinal) + ? primaryClient + : secondaryClient; + return Task.FromResult(client); + }); +#pragma warning restore CA2025 + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal(2, providerUrls.Count); + Assert.Equal(TestUrl, providerUrls[0]); + Assert.Equal("https://secondary.example.test/next", providerUrls[1]); + } + #endregion #region DisposeAsync @@ -506,4 +674,88 @@ protected override async Task SendAsync(HttpRequestMessage return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); } } + + private sealed class LoopbackServer : IAsyncDisposable + { + private readonly HttpListener _listener; + + private LoopbackServer(HttpListener listener, Uri baseUri, Task completion) + { + this._listener = listener; + this.BaseUri = baseUri; + this.Completion = completion; + } + + public Uri BaseUri { get; } + + public Task Completion { get; } + + public static Task StartAsync( + Func handler, + CancellationToken cancellationToken) + { + int port = GetAvailablePort(); + Uri baseUri = new($"http://127.0.0.1:{port}/"); + HttpListener listener = new(); + listener.Prefixes.Add(baseUri.ToString()); + listener.Start(); + Task completion = HandleSingleRequestAsync(listener, handler, cancellationToken); + return Task.FromResult(new LoopbackServer(listener, baseUri, completion)); + } + + public async ValueTask DisposeAsync() + { + this._listener.Close(); + + try + { + await this.Completion.ConfigureAwait(false); + } + catch (HttpListenerException) + { + } + catch (ObjectDisposedException) + { + } + } + + private static async Task HandleSingleRequestAsync( + HttpListener listener, + Func handler, + CancellationToken cancellationToken) + { + using CancellationTokenRegistration registration = cancellationToken.Register( + static state => ((HttpListener)state!).Close(), + listener); + HttpListenerContext context = await listener.GetContextAsync().ConfigureAwait(false); + await handler(context, cancellationToken).ConfigureAwait(false); + } + + private static int GetAvailablePort() + { + TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + } + + private static async Task WriteResponseAsync( + HttpListenerContext context, + HttpStatusCode statusCode, + string body, + CancellationToken cancellationToken) + { + byte[] bytes = Encoding.UTF8.GetBytes(body); + context.Response.StatusCode = (int)statusCode; + context.Response.ContentType = "text/plain"; + context.Response.ContentLength64 = bytes.Length; +#if NET + await context.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); +#else + await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); +#endif + context.Response.Close(); + } } From 8565d5b6d77f543e11cc5ad0d6fd80b0231232e6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:46:42 -0400 Subject: [PATCH 02/21] tests: use mocked handlers instead of simulated endpoints for reliability Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandlerTests.cs | 161 +++++------------- 1 file changed, 44 insertions(+), 117 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index e1267902394..cf85c88e641 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; -using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -387,64 +386,66 @@ public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() } [Fact] - public async Task SendAsyncOwnedClientDoesNotForwardRequestHeadersToRedirectedEndpointAsync() + public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; - const string HeaderValue = "request-header-value"; - List forwardedHeaderValues = []; - await using LoopbackServer secondaryEndpoint = await LoopbackServer.StartAsync(async (context, ct) => + List requestsWithHeader = []; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => { - forwardedHeaderValues.AddRange(context.Request.Headers.GetValues("X-Request-Token") ?? []); - await WriteResponseAsync(context, HttpStatusCode.OK, "redirect-ok", ct); - }, cancellationToken); + requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); + if (requestsWithHeader.Count == 1) + { + HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); + response.Headers.Location = new Uri("https://api.example.test/next"); + return Task.FromResult(response); + } - await using LoopbackServer primaryEndpoint = await LoopbackServer.StartAsync((context, _) => - { - context.Response.StatusCode = (int)HttpStatusCode.TemporaryRedirect; - context.Response.RedirectLocation = new Uri(secondaryEndpoint.BaseUri, "next").ToString(); - context.Response.Close(); - return Task.CompletedTask; - }, cancellationToken); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }); + }); +#pragma warning restore CA2025 - await using DefaultHttpRequestHandler handler = new(); + using HttpClient client = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(client); HttpRequestInfo request = new() { Method = "GET", - Url = new Uri(primaryEndpoint.BaseUri, "redirect").ToString(), + Url = TestUrl, Headers = new Dictionary { - ["X-Request-Token"] = HeaderValue, + ["X-Trace-Id"] = "trace-1", }, }; // Act HttpRequestResult result = await handler.SendAsync(request, cancellationToken); - await Task.WhenAll(primaryEndpoint.Completion, secondaryEndpoint.Completion); // Assert - Assert.Equal(200, result.StatusCode); - Assert.Equal("redirect-ok", result.Body); - Assert.Empty(forwardedHeaderValues); + Assert.Equal("redirected", result.Body); + Assert.Equal([true, false], requestsWithHeader); } [Fact] - public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() + public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpointAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; List requestsWithHeader = []; #pragma warning disable CA2025 - TestHttpMessageHandler messageHandler = new((req, _) => + TestHttpMessageHandler primaryMessageHandler = new((req, _) => + { + requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); + HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); + response.Headers.Location = new Uri("https://secondary.example.test/next"); + return Task.FromResult(response); + }); + TestHttpMessageHandler secondaryMessageHandler = new((req, _) => { requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); - if (requestsWithHeader.Count == 1) - { - HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); - response.Headers.Location = new Uri("https://api.example.test/next"); - return Task.FromResult(response); - } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), @@ -452,8 +453,18 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() }); #pragma warning restore CA2025 - using HttpClient client = new(messageHandler); - await using DefaultHttpRequestHandler handler = new(client); + using HttpClient primaryClient = new(primaryMessageHandler); + using HttpClient secondaryClient = new(secondaryMessageHandler); +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((info, _) => + { + HttpClient client = info.Url.StartsWith("https://api.example.test/", StringComparison.Ordinal) + ? primaryClient + : secondaryClient; + return Task.FromResult(client); + }); +#pragma warning restore CA2025 + HttpRequestInfo request = new() { Method = "GET", @@ -674,88 +685,4 @@ protected override async Task SendAsync(HttpRequestMessage return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); } } - - private sealed class LoopbackServer : IAsyncDisposable - { - private readonly HttpListener _listener; - - private LoopbackServer(HttpListener listener, Uri baseUri, Task completion) - { - this._listener = listener; - this.BaseUri = baseUri; - this.Completion = completion; - } - - public Uri BaseUri { get; } - - public Task Completion { get; } - - public static Task StartAsync( - Func handler, - CancellationToken cancellationToken) - { - int port = GetAvailablePort(); - Uri baseUri = new($"http://127.0.0.1:{port}/"); - HttpListener listener = new(); - listener.Prefixes.Add(baseUri.ToString()); - listener.Start(); - Task completion = HandleSingleRequestAsync(listener, handler, cancellationToken); - return Task.FromResult(new LoopbackServer(listener, baseUri, completion)); - } - - public async ValueTask DisposeAsync() - { - this._listener.Close(); - - try - { - await this.Completion.ConfigureAwait(false); - } - catch (HttpListenerException) - { - } - catch (ObjectDisposedException) - { - } - } - - private static async Task HandleSingleRequestAsync( - HttpListener listener, - Func handler, - CancellationToken cancellationToken) - { - using CancellationTokenRegistration registration = cancellationToken.Register( - static state => ((HttpListener)state!).Close(), - listener); - HttpListenerContext context = await listener.GetContextAsync().ConfigureAwait(false); - await handler(context, cancellationToken).ConfigureAwait(false); - } - - private static int GetAvailablePort() - { - TcpListener listener = new(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - } - - private static async Task WriteResponseAsync( - HttpListenerContext context, - HttpStatusCode statusCode, - string body, - CancellationToken cancellationToken) - { - byte[] bytes = Encoding.UTF8.GetBytes(body); - context.Response.StatusCode = (int)statusCode; - context.Response.ContentType = "text/plain"; - context.Response.ContentLength64 = bytes.Length; -#if NET - await context.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); -#else - await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); -#endif - context.Response.Close(); - } } From e3cc7b349529299a6d700bb9790343139a2897a8 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:47:07 -0400 Subject: [PATCH 03/21] docs: Clarify redirect handling in DefaultHttpRequestHandler Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DefaultHttpRequestHandler.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 9399e5101f0..d22e5d86551 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -26,8 +26,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// The handler applies the per-request using a linked /// so it does not mutate on shared instances. /// -/// -/// Redirects are handled by this handler so request credentials are not forwarded to a different origin. The +/// Redirects are handled by this handler so per-request headers are not forwarded to redirect destinations. The /// internally owned client disables automatic redirects. Supplied clients should also disable automatic /// redirects; clients with credential-bearing default headers are rejected because their redirect behavior is /// opaque to this handler. From 7f83f9a0633a354450d38933e2ae97fe97a9067f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:48:37 -0400 Subject: [PATCH 04/21] chore: Improve exception handling for invalid request URI Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DefaultHttpRequestHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index d22e5d86551..50c8abaea5e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -258,7 +258,7 @@ private static Uri CreateAbsoluteUri(string requestUri) { if (!Uri.TryCreate(requestUri, UriKind.Absolute, out Uri? uri)) { - throw new ArgumentException("Request URL must be an absolute URL."); + throw new ArgumentException("Request URL must be an absolute URL.", nameof(requestUri)); } return uri; From 38be03764d8b8dafc315f88812018b0e62a09332 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:52:46 -0400 Subject: [PATCH 05/21] docs: fixes xml doc structure Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 50c8abaea5e..f81058a1521 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -26,6 +26,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// The handler applies the per-request using a linked /// so it does not mutate on shared instances. /// +/// /// Redirects are handled by this handler so per-request headers are not forwarded to redirect destinations. The /// internally owned client disables automatic redirects. Supplied clients should also disable automatic /// redirects; clients with credential-bearing default headers are rejected because their redirect behavior is From 972c949f80de7edf684db5b6fb6251258c09f1fa Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:54:11 -0400 Subject: [PATCH 06/21] tests: linting Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandlerTests.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index cf85c88e641..08c36f7c8cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -397,9 +397,10 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); if (requestsWithHeader.Count == 1) { - HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); - response.Headers.Location = new Uri("https://api.example.test/next"); - return Task.FromResult(response); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }); } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) @@ -439,9 +440,10 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpoi TestHttpMessageHandler primaryMessageHandler = new((req, _) => { requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); - HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); - response.Headers.Location = new Uri("https://secondary.example.test/next"); - return Task.FromResult(response); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }); }); TestHttpMessageHandler secondaryMessageHandler = new((req, _) => { @@ -523,9 +525,10 @@ public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() #pragma warning disable CA2025 TestHttpMessageHandler primaryMessageHandler = new((req, _) => { - HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect); - response.Headers.Location = new Uri("https://secondary.example.test/next"); - return Task.FromResult(response); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }); }); TestHttpMessageHandler secondaryMessageHandler = new((req, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) From d67918d3a6989a97100b86232fe6bea70111c3dc Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 14:59:58 -0400 Subject: [PATCH 07/21] perf: only read headers for redirect responses Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 2 +- .../DefaultHttpRequestHandlerTests.cs | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index f81058a1521..98e43921390 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -150,7 +150,7 @@ public async Task SendAsync(HttpRequestInfo request, Cancella CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; using HttpResponseMessage httpResponse = await client - .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) + .SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, effectiveToken) .ConfigureAwait(false); if (TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 08c36f7c8cd..a492038524f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Http; using System.Text; @@ -485,6 +486,47 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpoi Assert.Equal([true, false], requestsWithHeader); } + [Fact] + public async Task SendAsyncDoesNotReadRedirectedResponseBodyAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + TrackingContent redirectedContent = new("not returned"); +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + if (req.RequestUri!.AbsolutePath == "/resource") + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Content = redirectedContent, + Headers = { Location = new Uri("https://api.example.test/next") }, + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }); + }); +#pragma warning restore CA2025 + + using HttpClient client = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(client); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.False(redirectedContent.WasRead); + } + [Fact] public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersAsync() { @@ -688,4 +730,29 @@ protected override async Task SendAsync(HttpRequestMessage return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); } } + + private sealed class TrackingContent : HttpContent + { + private readonly string _content; + + public TrackingContent(string content) + { + this._content = content; + } + + public bool WasRead { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + this.WasRead = true; + byte[] bytes = Encoding.UTF8.GetBytes(this._content); + return stream.WriteAsync(bytes, 0, bytes.Length); + } + + protected override bool TryComputeLength(out long length) + { + length = Encoding.UTF8.GetByteCount(this._content); + return true; + } + } } From a443079f6cdfb2c4ad3ed18f31ad93f90a951949 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 15:04:37 -0400 Subject: [PATCH 08/21] fix: aggregate timeout across redirects Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 18 ++++---- .../DefaultHttpRequestHandlerTests.cs | 42 +++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 98e43921390..846aa884a36 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -124,12 +124,20 @@ public async Task SendAsync(HttpRequestInfo request, Cancella HttpRequestInfo currentRequest = request; Uri currentUri = CreateAbsoluteUri(ResolveRequestUri(request)); + using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + + timeoutCts?.CancelAfter(request.Timeout!.Value); + + CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; + for (int redirectCount = 0; redirectCount <= MaxAutomaticRedirections; redirectCount++) { HttpClient? providedClient = null; if (this._httpClientProvider is not null) { - providedClient = await this._httpClientProvider(currentRequest, cancellationToken).ConfigureAwait(false); + providedClient = await this._httpClientProvider(currentRequest, effectiveToken).ConfigureAwait(false); } if (providedClient is not null) @@ -141,14 +149,6 @@ public async Task SendAsync(HttpRequestInfo request, Cancella using HttpRequestMessage httpRequest = BuildHttpRequestMessage(currentRequest); - using CancellationTokenSource? timeoutCts = currentRequest.Timeout is { } timeout && timeout > TimeSpan.Zero - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) - : null; - - timeoutCts?.CancelAfter(currentRequest.Timeout!.Value); - - CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; - using HttpResponseMessage httpResponse = await client .SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, effectiveToken) .ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index a492038524f..1172dfd328c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -365,6 +365,48 @@ public async Task SendAsyncTimeoutCancelsRequestAsync() await Assert.ThrowsAnyAsync(actAsync); } + [Fact] + public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + TestHttpMessageHandler messageHandler = new(async (req, ct) => + { + requestCount++; + await Task.Delay(TimeSpan.FromMilliseconds(200), ct).ConfigureAwait(false); + + if (requestCount == 1) + { + return new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; + }); + + using HttpClient client = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(client); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Timeout = TimeSpan.FromMilliseconds(300), + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + await Assert.ThrowsAnyAsync(actAsync); + Assert.Equal(2, requestCount); + } + [Fact] public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() { From e6d0dbb2f7420d90883ae6b7edd842c38f884844 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 15:08:13 -0400 Subject: [PATCH 09/21] fix: prevent https to http redirects Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 9 +++++ .../DefaultHttpRequestHandlerTests.cs | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 846aa884a36..248f9e22d56 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -284,6 +284,11 @@ private static bool TryCreateRedirectRequest( ? response.Headers.Location : new Uri(currentUri, response.Headers.Location); + if (IsHttpsToHttpRedirect(currentUri, redirectUri)) + { + throw new HttpRequestException("Redirects from HTTPS to HTTP are not allowed."); + } + bool rewriteToGet = ShouldRewriteRedirectMethodToGet(response.StatusCode, currentRequest.Method); redirectRequest = new HttpRequestInfo @@ -312,6 +317,10 @@ private static bool ShouldRewriteRedirectMethodToGet(HttpStatusCode statusCode, return code == 303 || ((code == 301 || code == 302) && string.Equals(normalized, "POST", StringComparison.Ordinal)); } + private static bool IsHttpsToHttpRedirect(Uri currentUri, Uri redirectUri) => + string.Equals(currentUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(redirectUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + private static void ThrowIfUnsafeProvidedClientHeaders(HttpClient providedClient, HttpRequestInfo request) { if (providedClient.DefaultRequestHeaders.Any(header => IsSensitiveHeaderName(header.Key))) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 1172dfd328c..1134ef206af 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -569,6 +569,42 @@ public async Task SendAsyncDoesNotReadRedirectedResponseBodyAsync() Assert.False(redirectedContent.WasRead); } + [Fact] + public async Task SendAsyncRejectsHttpsToHttpRedirectAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("http://api.example.test/next") }, + }); + }); +#pragma warning restore CA2025 + + using HttpClient client = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(client); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("HTTPS to HTTP", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, requestCount); + } + [Fact] public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersAsync() { From e5439531f64a9497714df604161b1bc7fdf234fd Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 15:20:23 -0400 Subject: [PATCH 10/21] fix: do not reject provider clients on initial request Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 2 +- .../DefaultHttpRequestHandlerTests.cs | 51 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 248f9e22d56..69a782b5f5e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -140,7 +140,7 @@ public async Task SendAsync(HttpRequestInfo request, Cancella providedClient = await this._httpClientProvider(currentRequest, effectiveToken).ConfigureAwait(false); } - if (providedClient is not null) + if (providedClient is not null && redirectCount > 0) { ThrowIfUnsafeProvidedClientHeaders(providedClient, currentRequest); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 1134ef206af..acfb8ca4418 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -606,11 +606,16 @@ public async Task SendAsyncRejectsHttpsToHttpRedirectAsync() } [Fact] - public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersAsync() + public async Task SendAsyncProviderClientAllowsScopedDefaultHeadersOnInitialRequestAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; - using HttpClient providerClient = new(); + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + })); + using HttpClient providerClient = new(messageHandler); providerClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); int providerCallCount = 0; @@ -628,13 +633,53 @@ public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersAsync() Url = TestUrl, }; + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("ok", result.Body); + Assert.Equal(1, providerCallCount); + } + + [Fact] + public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersOnRedirectedRequestAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + TestHttpMessageHandler primaryMessageHandler = new((req, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + })); + using HttpClient primaryClient = new(primaryMessageHandler); + using HttpClient secondaryClient = new(); + secondaryClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); + + int providerCallCount = 0; +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((info, _) => + { + providerCallCount++; + HttpClient client = info.Url.StartsWith("https://api.example.test/", StringComparison.Ordinal) + ? primaryClient + : secondaryClient; + return Task.FromResult(client); + }); +#pragma warning restore CA2025 + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + // Act async Task actAsync() => await handler.SendAsync(request, cancellationToken); // Assert InvalidOperationException exception = await Assert.ThrowsAsync(actAsync); Assert.Contains("DefaultRequestHeaders", exception.Message, StringComparison.Ordinal); - Assert.Equal(1, providerCallCount); + Assert.Equal(2, providerCallCount); } [Fact] From 9b5da789603a86984c0ac84a3d02f52e1e5039c5 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Tue, 8 Sep 2026 15:33:09 -0400 Subject: [PATCH 11/21] tests: refactor to avoid missing disposals Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandlerTests.cs | 125 ++++++++++-------- 1 file changed, 70 insertions(+), 55 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index acfb8ca4418..f5fb42238a7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -371,6 +371,14 @@ public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; int requestCount = 0; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; TestHttpMessageHandler messageHandler = new(async (req, ct) => { requestCount++; @@ -378,16 +386,10 @@ public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() if (requestCount == 1) { - return new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Headers = { Location = new Uri("https://api.example.test/next") }, - }; + return redirectResponse; } - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("ok", Encoding.UTF8, "text/plain"), - }; + return okResponse; }); using HttpClient client = new(messageHandler); @@ -434,22 +436,24 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; List requestsWithHeader = []; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; #pragma warning disable CA2025 TestHttpMessageHandler messageHandler = new((req, _) => { requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); if (requestsWithHeader.Count == 1) { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Headers = { Location = new Uri("https://api.example.test/next") }, - }); + return Task.FromResult(redirectResponse); } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), - }); + return Task.FromResult(okResponse); }); #pragma warning restore CA2025 @@ -479,22 +483,24 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpoi // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; List requestsWithHeader = []; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; #pragma warning disable CA2025 TestHttpMessageHandler primaryMessageHandler = new((req, _) => { requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Headers = { Location = new Uri("https://secondary.example.test/next") }, - }); + return Task.FromResult(redirectResponse); }); TestHttpMessageHandler secondaryMessageHandler = new((req, _) => { requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), - }); + return Task.FromResult(okResponse); }); #pragma warning restore CA2025 @@ -534,22 +540,24 @@ public async Task SendAsyncDoesNotReadRedirectedResponseBodyAsync() // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; TrackingContent redirectedContent = new("not returned"); + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Content = redirectedContent, + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; #pragma warning disable CA2025 TestHttpMessageHandler messageHandler = new((req, _) => { if (req.RequestUri!.AbsolutePath == "/resource") { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Content = redirectedContent, - Headers = { Location = new Uri("https://api.example.test/next") }, - }); + return Task.FromResult(redirectResponse); } - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), - }); + return Task.FromResult(okResponse); }); #pragma warning restore CA2025 @@ -575,14 +583,15 @@ public async Task SendAsyncRejectsHttpsToHttpRedirectAsync() // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; int requestCount = 0; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("http://api.example.test/next") }, + }; #pragma warning disable CA2025 TestHttpMessageHandler messageHandler = new((req, _) => { requestCount++; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Headers = { Location = new Uri("http://api.example.test/next") }, - }); + return Task.FromResult(redirectResponse); }); #pragma warning restore CA2025 @@ -610,11 +619,14 @@ public async Task SendAsyncProviderClientAllowsScopedDefaultHeadersOnInitialRequ { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 TestHttpMessageHandler messageHandler = new((req, _) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("ok", Encoding.UTF8, "text/plain"), - })); + Task.FromResult(okResponse)); +#pragma warning restore CA2025 using HttpClient providerClient = new(messageHandler); providerClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); @@ -646,11 +658,14 @@ public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersOnRedirected { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; +#pragma warning disable CA2025 TestHttpMessageHandler primaryMessageHandler = new((req, _) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Headers = { Location = new Uri("https://secondary.example.test/next") }, - })); + Task.FromResult(redirectResponse)); +#pragma warning restore CA2025 using HttpClient primaryClient = new(primaryMessageHandler); using HttpClient secondaryClient = new(); secondaryClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); @@ -687,19 +702,19 @@ public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; #pragma warning disable CA2025 TestHttpMessageHandler primaryMessageHandler = new((req, _) => - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) - { - Headers = { Location = new Uri("https://secondary.example.test/next") }, - }); - }); + Task.FromResult(redirectResponse)); TestHttpMessageHandler secondaryMessageHandler = new((req, _) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), - })); + Task.FromResult(okResponse)); #pragma warning restore CA2025 using HttpClient primaryClient = new(primaryMessageHandler); From 971e8887cb53cda9c4921d9833655e8e59a20189 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 11:01:22 -0400 Subject: [PATCH 12/21] fix: do not rely on specific headers names Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 42 ++------- .../DefaultHttpRequestHandlerTests.cs | 87 ++++++++----------- 2 files changed, 45 insertions(+), 84 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 69a782b5f5e..f3725c6e981 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -29,8 +29,8 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// /// Redirects are handled by this handler so per-request headers are not forwarded to redirect destinations. The /// internally owned client disables automatic redirects. Supplied clients should also disable automatic -/// redirects; clients with credential-bearing default headers are rejected because their redirect behavior is -/// opaque to this handler. +/// redirects and handle redirect responses before returning them to this handler because their redirect behavior +/// is opaque to this handler. /// /// public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable @@ -140,11 +140,6 @@ public async Task SendAsync(HttpRequestInfo request, Cancella providedClient = await this._httpClientProvider(currentRequest, effectiveToken).ConfigureAwait(false); } - if (providedClient is not null && redirectCount > 0) - { - ThrowIfUnsafeProvidedClientHeaders(providedClient, currentRequest); - } - HttpClient client = providedClient ?? this._ownedHttpClient.Value; using HttpRequestMessage httpRequest = BuildHttpRequestMessage(currentRequest); @@ -155,6 +150,12 @@ public async Task SendAsync(HttpRequestInfo request, Cancella if (TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri)) { + if (providedClient is not null) + { + throw new InvalidOperationException( + "DefaultHttpRequestHandler cannot safely follow redirects when using a caller-supplied HttpClient because the client's redirect behavior is opaque. Use the handler-owned client for redirect handling, or handle redirects with an origin-pinned transport before returning the response."); + } + currentRequest = redirectRequest; currentUri = redirectUri; continue; @@ -321,33 +322,6 @@ private static bool IsHttpsToHttpRedirect(Uri currentUri, Uri redirectUri) => string.Equals(currentUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && string.Equals(redirectUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); - private static void ThrowIfUnsafeProvidedClientHeaders(HttpClient providedClient, HttpRequestInfo request) - { - if (providedClient.DefaultRequestHeaders.Any(header => IsSensitiveHeaderName(header.Key))) - { - throw new InvalidOperationException( - "DefaultHttpRequestHandler cannot safely use a provided HttpClient with credential-bearing DefaultRequestHeaders because the client may forward them during automatic redirects. Configure credentials with an origin-pinning handler that disables automatic redirects."); - } - - if (request.Headers?.Keys.Any(IsCustomCredentialHeaderName) == true) - { - throw new InvalidOperationException( - "DefaultHttpRequestHandler cannot safely send credential-bearing request headers through a provided HttpClient because the client may forward them during automatic redirects. Use the handler-owned client or configure an origin-pinning handler that disables automatic redirects."); - } - } - - private static bool IsSensitiveHeaderName(string headerName) => - string.Equals(headerName, "Authorization", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "Proxy-Authorization", StringComparison.OrdinalIgnoreCase) || - string.Equals(headerName, "Cookie", StringComparison.OrdinalIgnoreCase) || - IsCustomCredentialHeaderName(headerName); - - private static bool IsCustomCredentialHeaderName(string headerName) => - headerName.Contains("Api-Key", StringComparison.OrdinalIgnoreCase) || - headerName.Contains("Token", StringComparison.OrdinalIgnoreCase) || - headerName.Contains("Secret", StringComparison.OrdinalIgnoreCase) || - headerName.Contains("Credential", StringComparison.OrdinalIgnoreCase); - private static HttpMethod ResolveMethod(string method) { string normalized = method.Trim().ToUpperInvariant(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index f5fb42238a7..fb5985a8c36 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Net; using System.Net.Http; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -392,8 +393,7 @@ public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() return okResponse; }); - using HttpClient client = new(messageHandler); - await using DefaultHttpRequestHandler handler = new(client); + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); HttpRequestInfo request = new() { Method = "GET", @@ -457,8 +457,7 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() }); #pragma warning restore CA2025 - using HttpClient client = new(messageHandler); - await using DefaultHttpRequestHandler handler = new(client); + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); HttpRequestInfo request = new() { Method = "GET", @@ -483,6 +482,7 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpoi // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; List requestsWithHeader = []; + List requestUrls = []; using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) { Headers = { Location = new Uri("https://secondary.example.test/next") }, @@ -492,29 +492,15 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpoi Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), }; #pragma warning disable CA2025 - TestHttpMessageHandler primaryMessageHandler = new((req, _) => - { - requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); - return Task.FromResult(redirectResponse); - }); - TestHttpMessageHandler secondaryMessageHandler = new((req, _) => + TestHttpMessageHandler messageHandler = new((req, _) => { + requestUrls.Add(req.RequestUri!.ToString()); requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); - return Task.FromResult(okResponse); + return Task.FromResult(requestUrls.Count == 1 ? redirectResponse : okResponse); }); #pragma warning restore CA2025 - using HttpClient primaryClient = new(primaryMessageHandler); - using HttpClient secondaryClient = new(secondaryMessageHandler); -#pragma warning disable CA2025 - await using DefaultHttpRequestHandler handler = new((info, _) => - { - HttpClient client = info.Url.StartsWith("https://api.example.test/", StringComparison.Ordinal) - ? primaryClient - : secondaryClient; - return Task.FromResult(client); - }); -#pragma warning restore CA2025 + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); HttpRequestInfo request = new() { @@ -532,6 +518,7 @@ public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpoi // Assert Assert.Equal("redirected", result.Body); Assert.Equal([true, false], requestsWithHeader); + Assert.Equal([TestUrl, "https://secondary.example.test/next"], requestUrls); } [Fact] @@ -561,8 +548,7 @@ public async Task SendAsyncDoesNotReadRedirectedResponseBodyAsync() }); #pragma warning restore CA2025 - using HttpClient client = new(messageHandler); - await using DefaultHttpRequestHandler handler = new(client); + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); HttpRequestInfo request = new() { Method = "GET", @@ -595,8 +581,7 @@ public async Task SendAsyncRejectsHttpsToHttpRedirectAsync() }); #pragma warning restore CA2025 - using HttpClient client = new(messageHandler); - await using DefaultHttpRequestHandler handler = new(client); + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); HttpRequestInfo request = new() { Method = "POST", @@ -654,7 +639,7 @@ public async Task SendAsyncProviderClientAllowsScopedDefaultHeadersOnInitialRequ } [Fact] - public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersOnRedirectedRequestAsync() + public async Task SendAsyncSuppliedClientRejectsRedirectResponseAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; @@ -667,18 +652,14 @@ public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersOnRedirected Task.FromResult(redirectResponse)); #pragma warning restore CA2025 using HttpClient primaryClient = new(primaryMessageHandler); - using HttpClient secondaryClient = new(); - secondaryClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); + primaryClient.DefaultRequestHeaders.TryAddWithoutValidation("Ocp-Apim-Subscription-Key", "provider-header-value"); int providerCallCount = 0; #pragma warning disable CA2025 - await using DefaultHttpRequestHandler handler = new((info, _) => + await using DefaultHttpRequestHandler handler = new((_, _) => { providerCallCount++; - HttpClient client = info.Url.StartsWith("https://api.example.test/", StringComparison.Ordinal) - ? primaryClient - : secondaryClient; - return Task.FromResult(client); + return Task.FromResult(primaryClient); }); #pragma warning restore CA2025 @@ -693,8 +674,8 @@ public async Task SendAsyncProviderClientRejectsScopedDefaultHeadersOnRedirected // Assert InvalidOperationException exception = await Assert.ThrowsAsync(actAsync); - Assert.Contains("DefaultRequestHeaders", exception.Message, StringComparison.Ordinal); - Assert.Equal(2, providerCallCount); + Assert.Contains("caller-supplied HttpClient", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, providerCallCount); } [Fact] @@ -702,6 +683,8 @@ public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List providerUrls = []; + List requestUrls = []; using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) { Headers = { Location = new Uri("https://secondary.example.test/next") }, @@ -711,25 +694,18 @@ public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), }; #pragma warning disable CA2025 - TestHttpMessageHandler primaryMessageHandler = new((req, _) => - Task.FromResult(redirectResponse)); - TestHttpMessageHandler secondaryMessageHandler = new((req, _) => - Task.FromResult(okResponse)); + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestUrls.Add(req.RequestUri!.ToString()); + return Task.FromResult(requestUrls.Count == 1 ? redirectResponse : okResponse); + }); #pragma warning restore CA2025 - using HttpClient primaryClient = new(primaryMessageHandler); - using HttpClient secondaryClient = new(secondaryMessageHandler); - List providerUrls = []; -#pragma warning disable CA2025 - await using DefaultHttpRequestHandler handler = new((info, _) => + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler, (info, _) => { providerUrls.Add(info.Url); - HttpClient client = info.Url.StartsWith("https://api.example.test/", StringComparison.Ordinal) - ? primaryClient - : secondaryClient; - return Task.FromResult(client); + return Task.FromResult(null); }); -#pragma warning restore CA2025 HttpRequestInfo request = new() { @@ -745,6 +721,7 @@ public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() Assert.Equal(2, providerUrls.Count); Assert.Equal(TestUrl, providerUrls[0]); Assert.Equal("https://secondary.example.test/next", providerUrls[1]); + Assert.Equal(providerUrls, requestUrls); } #endregion @@ -838,6 +815,16 @@ public async Task QueryParametersPreserveExistingQueryStringAsync() #endregion + private static DefaultHttpRequestHandler CreateHandlerWithOwnedMessageHandler( + HttpMessageHandler ownedHttpMessageHandler, + Func>? httpClientProvider = null) + { + DefaultHttpRequestHandler handler = new(httpClientProvider); + FieldInfo ownedHttpClientField = typeof(DefaultHttpRequestHandler).GetField("_ownedHttpClient", BindingFlags.Instance | BindingFlags.NonPublic)!; + ownedHttpClientField.SetValue(handler, new Lazy(() => new HttpClient(ownedHttpMessageHandler), LazyThreadSafetyMode.ExecutionAndPublication)); + return handler; + } + private sealed class TestHttpMessageHandler : HttpMessageHandler { private readonly Func> _responseFactory; From 1d300c617a489f822b8edf9d784b9c18390f3742 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:22:50 +0000 Subject: [PATCH 13/21] fix: address redirect review feedback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .../DefaultHttpRequestHandler.cs | 8 +- .../DefaultHttpRequestHandlerTests.cs | 129 +++++++++++++++++- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index f3725c6e981..6d1830aafa1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -27,10 +27,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// so it does not mutate on shared instances. /// /// -/// Redirects are handled by this handler so per-request headers are not forwarded to redirect destinations. The -/// internally owned client disables automatic redirects. Supplied clients should also disable automatic -/// redirects and handle redirect responses before returning them to this handler because their redirect behavior -/// is opaque to this handler. +/// Redirects are handled by this handler only when using its internally owned client, which disables automatic +/// redirects so per-request headers are not forwarded to redirect destinations. If a supplied client returns a +/// redirect response, this handler rejects it because the client's redirect behavior is opaque. Supplied clients +/// should disable automatic redirects and handle redirect responses before returning them to this handler. /// /// public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index fb5985a8c36..3d63f65d1e6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -383,7 +383,8 @@ public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() TestHttpMessageHandler messageHandler = new(async (req, ct) => { requestCount++; - await Task.Delay(TimeSpan.FromMilliseconds(200), ct).ConfigureAwait(false); + TimeSpan delay = requestCount == 1 ? TimeSpan.FromMilliseconds(1) : TimeSpan.FromSeconds(5); + await Task.Delay(delay, ct).ConfigureAwait(false); if (requestCount == 1) { @@ -409,6 +410,119 @@ public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() Assert.Equal(2, requestCount); } + [Fact] + public async Task SendAsyncPostFoundRedirectRewritesToGetAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.Found) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; + int requestCount = 0; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + return Task.FromResult(requestCount == 1 ? redirectResponse : okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal(["POST", "GET"], messageHandler.RequestMethods); + Assert.Equal(["request-body", null], messageHandler.RequestBodies); + } + + [Theory] + [InlineData(307)] + [InlineData(308)] + public async Task SendAsyncPostPreserveMethodRedirectPreservesBodyAsync(int redirectStatusCode) + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new((HttpStatusCode)redirectStatusCode) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; + int requestCount = 0; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestCount++; + return Task.FromResult(requestCount == 1 ? redirectResponse : okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal(["POST", "POST"], messageHandler.RequestMethods); + Assert.Equal(["request-body", "request-body"], messageHandler.RequestBodies); + } + + [Fact] + public async Task SendAsyncTooManyRedirectsThrowsAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri($"https://api.example.test/redirect/{requestCount}") }, + }); + }); + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("maximum number of HTTP redirects", exception.Message, StringComparison.Ordinal); + Assert.Equal(51, requestCount); + } + [Fact] public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() { @@ -820,7 +934,8 @@ private static DefaultHttpRequestHandler CreateHandlerWithOwnedMessageHandler( Func>? httpClientProvider = null) { DefaultHttpRequestHandler handler = new(httpClientProvider); - FieldInfo ownedHttpClientField = typeof(DefaultHttpRequestHandler).GetField("_ownedHttpClient", BindingFlags.Instance | BindingFlags.NonPublic)!; + FieldInfo? ownedHttpClientField = typeof(DefaultHttpRequestHandler).GetField("_ownedHttpClient", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(ownedHttpClientField); ownedHttpClientField.SetValue(handler, new Lazy(() => new HttpClient(ownedHttpMessageHandler), LazyThreadSafetyMode.ExecutionAndPublication)); return handler; } @@ -840,9 +955,14 @@ public TestHttpMessageHandler(Func RequestMethods { get; } = []; + + public List RequestBodies { get; } = []; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { this.LastRequest = request; + this.RequestMethods.Add(request.Method.Method); if (request.Content is not null) { #if NET @@ -850,8 +970,13 @@ protected override async Task SendAsync(HttpRequestMessage #else this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); #endif + this.RequestBodies.Add(this.LastRequestBody); this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType; } + else + { + this.RequestBodies.Add(null); + } return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); } } From c2aaa0d727c6a5719e60c4aa8187fbe09e1b3263 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 12:31:53 -0400 Subject: [PATCH 14/21] tests: ensure responses are disposed Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../DefaultHttpRequestHandlerTests.cs | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 3d63f65d1e6..13e0c08e03a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -498,29 +498,43 @@ public async Task SendAsyncTooManyRedirectsThrowsAsync() // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; int requestCount = 0; + List createdResponses = []; TestHttpMessageHandler messageHandler = new((_, _) => { requestCount++; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.TemporaryRedirect) + HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect) { Headers = { Location = new Uri($"https://api.example.test/redirect/{requestCount}") }, - }); + }; + + createdResponses.Add(response); + return Task.FromResult(response); }); - await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); - HttpRequestInfo request = new() + try { - Method = "GET", - Url = TestUrl, - }; + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; - // Act - async Task actAsync() => await handler.SendAsync(request, cancellationToken); + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); - // Assert - HttpRequestException exception = await Assert.ThrowsAsync(actAsync); - Assert.Contains("maximum number of HTTP redirects", exception.Message, StringComparison.Ordinal); - Assert.Equal(51, requestCount); + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("maximum number of HTTP redirects", exception.Message, StringComparison.Ordinal); + Assert.Equal(51, requestCount); + } + finally + { + foreach (HttpResponseMessage response in createdResponses) + { + response.Dispose(); + } + } } [Fact] From f29e8571533c9472ca9f435dcedfde1c57e5be23 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 13:34:30 -0400 Subject: [PATCH 15/21] chore: suppression of linting rule Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandlerTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 13e0c08e03a..16c00802189 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -508,7 +508,9 @@ public async Task SendAsyncTooManyRedirectsThrowsAsync() }; createdResponses.Add(response); +#pragma warning disable CA2025 return Task.FromResult(response); +#pragma warning restore CA2025 }); try From 62fb41e7e0d05ba028d9de614a970ce2bd9239bf Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 14:29:59 -0400 Subject: [PATCH 16/21] fix: reject redirect with body to different origin Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 12 ++++++ .../DefaultHttpRequestHandlerTests.cs | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 6d1830aafa1..1d776ea870e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -291,6 +291,10 @@ private static bool TryCreateRedirectRequest( } bool rewriteToGet = ShouldRewriteRedirectMethodToGet(response.StatusCode, currentRequest.Method); + if (!rewriteToGet && currentRequest.Body is not null && !HaveSameOrigin(currentUri, redirectUri)) + { + throw new HttpRequestException("Redirects that preserve the request body to a different origin are not allowed."); + } redirectRequest = new HttpRequestInfo { @@ -322,6 +326,14 @@ private static bool IsHttpsToHttpRedirect(Uri currentUri, Uri redirectUri) => string.Equals(currentUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && string.Equals(redirectUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + private static bool HaveSameOrigin(Uri currentUri, Uri redirectUri) => + Uri.Compare( + currentUri, + redirectUri, + UriComponents.SchemeAndServer, + UriFormat.Unescaped, + StringComparison.OrdinalIgnoreCase) == 0; + private static HttpMethod ResolveMethod(string method) { string normalized = method.Trim().ToUpperInvariant(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 16c00802189..93a571fa224 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -492,6 +492,44 @@ public async Task SendAsyncPostPreserveMethodRedirectPreservesBodyAsync(int redi Assert.Equal(["request-body", "request-body"], messageHandler.RequestBodies); } + [Theory] + [InlineData(307)] + [InlineData(308)] + public async Task SendAsyncPostPreserveMethodRedirectToDifferentOriginWithBodyThrowsAsync(int redirectStatusCode) + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + using HttpResponseMessage redirectResponse = new((HttpStatusCode)redirectStatusCode) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + return Task.FromResult(redirectResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("preserve the request body to a different origin", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, requestCount); + } + [Fact] public async Task SendAsyncTooManyRedirectsThrowsAsync() { From 81a0995b8d080600463ddfec4990a1270489340d Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 14:36:39 -0400 Subject: [PATCH 17/21] fix: timeout in dotnet fx as well Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 31 ++++++++++-- .../DefaultHttpRequestHandlerTests.cs | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 1d776ea870e..74e771c3cfa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -163,11 +163,7 @@ public async Task SendAsync(HttpRequestInfo request, Cancella string? body = httpResponse.Content is null ? null -#if NET - : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); -#else - : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif + : await ReadResponseBodyAsStringAsync(httpResponse.Content, effectiveToken).ConfigureAwait(false); Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); AppendHeaders(headers, httpResponse.Headers); @@ -188,6 +184,31 @@ public async Task SendAsync(HttpRequestInfo request, Cancella throw new HttpRequestException($"The maximum number of HTTP redirects ({MaxAutomaticRedirections}) was exceeded."); } + private static async Task ReadResponseBodyAsStringAsync(HttpContent content, CancellationToken cancellationToken) + { +#if NET + return await content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#else + Task readTask = content.ReadAsStringAsync(); + Task cancellationTask = Task.Delay(Timeout.Infinite, cancellationToken); + Task completedTask = await Task.WhenAny(readTask, cancellationTask).ConfigureAwait(false); + if (completedTask == readTask) + { + return await readTask.ConfigureAwait(false); + } + + content.Dispose(); + _ = readTask.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + cancellationToken.ThrowIfCancellationRequested(); + throw new OperationCanceledException(cancellationToken); +#endif + } + /// public ValueTask DisposeAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 93a571fa224..3a9310714b2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -366,6 +366,37 @@ public async Task SendAsyncTimeoutCancelsRequestAsync() await Assert.ThrowsAnyAsync(actAsync); } + [Fact] + public async Task SendAsyncTimeoutCancelsResponseBodyReadAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StallingContent(), + }); + }); + + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Timeout = TimeSpan.FromMilliseconds(50), + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + await Assert.ThrowsAnyAsync(actAsync); + Assert.Equal(1, requestCount); + } + [Fact] public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() { @@ -1059,4 +1090,21 @@ protected override bool TryComputeLength(out long length) return true; } } + + private sealed class StallingContent : HttpContent + { + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + Task.Delay(Timeout.Infinite); + +#if NET + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + Task.Delay(Timeout.Infinite, cancellationToken); +#endif + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } } From 44cac6115fcd183970807811ce194cc87ea4224f Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 14:43:35 -0400 Subject: [PATCH 18/21] fix: return the redirect response instead of throwing Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandler.cs | 9 ++------- .../DefaultHttpRequestHandlerTests.cs | 10 ++++++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 74e771c3cfa..5a92941741d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -148,14 +148,9 @@ public async Task SendAsync(HttpRequestInfo request, Cancella .SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, effectiveToken) .ConfigureAwait(false); - if (TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri)) + if (providedClient is null && + TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri)) { - if (providedClient is not null) - { - throw new InvalidOperationException( - "DefaultHttpRequestHandler cannot safely follow redirects when using a caller-supplied HttpClient because the client's redirect behavior is opaque. Use the handler-owned client for redirect handling, or handle redirects with an origin-pinned transport before returning the response."); - } - currentRequest = redirectRequest; currentUri = redirectUri; continue; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 3a9310714b2..770a9eb3b75 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -838,7 +838,7 @@ public async Task SendAsyncProviderClientAllowsScopedDefaultHeadersOnInitialRequ } [Fact] - public async Task SendAsyncSuppliedClientRejectsRedirectResponseAsync() + public async Task SendAsyncSuppliedClientReturnsRedirectResponseAsync() { // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; @@ -869,11 +869,13 @@ public async Task SendAsyncSuppliedClientRejectsRedirectResponseAsync() }; // Act - async Task actAsync() => await handler.SendAsync(request, cancellationToken); + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); // Assert - InvalidOperationException exception = await Assert.ThrowsAsync(actAsync); - Assert.Contains("caller-supplied HttpClient", exception.Message, StringComparison.Ordinal); + Assert.Equal(307, result.StatusCode); + Assert.False(result.IsSuccessStatusCode); + Assert.NotNull(result.Headers); + Assert.Equal("https://secondary.example.test/next", Assert.Single(result.Headers!["Location"])); Assert.Equal(1, providerCallCount); } From 23993cb0c7cbe7c6841b89f69306f6e9b77be4cb Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 14:55:15 -0400 Subject: [PATCH 19/21] Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DefaultHttpRequestHandler.cs | 4 ++-- .../DefaultHttpRequestHandlerTests.cs | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 5a92941741d..72ad1d57409 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -29,8 +29,8 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// /// Redirects are handled by this handler only when using its internally owned client, which disables automatic /// redirects so per-request headers are not forwarded to redirect destinations. If a supplied client returns a -/// redirect response, this handler rejects it because the client's redirect behavior is opaque. Supplied clients -/// should disable automatic redirects and handle redirect responses before returning them to this handler. +/// redirect response, this handler does not follow it because the client's redirect behavior is opaque. Supplied +/// clients should disable automatic redirects and handle redirect responses before returning them to this handler. /// /// public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 770a9eb3b75..9d9f4595937 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -1095,8 +1095,10 @@ protected override bool TryComputeLength(out long length) private sealed class StallingContent : HttpContent { + private readonly TaskCompletionSource _stall = new(TaskCreationOptions.RunContinuationsAsynchronously); + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => - Task.Delay(Timeout.Infinite); + this._stall.Task; #if NET protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => @@ -1108,5 +1110,15 @@ protected override bool TryComputeLength(out long length) length = 0; return false; } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this._stall.TrySetCanceled(); + } + + base.Dispose(disposing); + } } } From 9dfa1b4e8aafad9c18eec19164a71691001980fb Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 14:55:58 -0400 Subject: [PATCH 20/21] Potential fix for pull request finding 'Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../DefaultHttpRequestHandlerTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 9d9f4595937..872b6ee5af9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -381,7 +381,8 @@ public async Task SendAsyncTimeoutCancelsResponseBodyReadAsync() }); }); - await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(new HttpClient(messageHandler))); + using HttpClient httpClient = new(messageHandler); + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(httpClient)); HttpRequestInfo request = new() { Method = "GET", From 6fc25b0e2c6bdbfc790aa73c667a74a2f83531b1 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 9 Sep 2026 14:57:12 -0400 Subject: [PATCH 21/21] chore: linting Signed-off-by: Vincent Biret --- .../DefaultHttpRequestHandlerTests.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 872b6ee5af9..33dbfd17cef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -372,17 +372,22 @@ public async Task SendAsyncTimeoutCancelsResponseBodyReadAsync() // Arrange CancellationToken cancellationToken = TestContext.Current.CancellationToken; int requestCount = 0; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StallingContent(), + }; TestHttpMessageHandler messageHandler = new((_, _) => { requestCount++; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StallingContent(), - }); +#pragma warning disable CA2025 // Do not pass 'IDisposable' instances into unawaited tasks + return Task.FromResult(response); +#pragma warning restore CA2025 // Do not pass 'IDisposable' instances into unawaited tasks }); using HttpClient httpClient = new(messageHandler); +#pragma warning disable CA2025 // Do not pass 'IDisposable' instances into unawaited tasks await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(httpClient)); +#pragma warning restore CA2025 // Do not pass 'IDisposable' instances into unawaited tasks HttpRequestInfo request = new() { Method = "GET",