diff --git a/.vscode/settings.json b/.vscode/settings.json index 11ff985d..a0c4067c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ "Kiota" ], "editor.formatOnSave": true, - "dotnet-test-explorer.testProjectPath": "**/*.Tests.csproj" + "dotnet-test-explorer.testProjectPath": "**/*.Tests.csproj", + "sarif-viewer.connectToGithubCodeScanning": "on" } \ No newline at end of file diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs index 0aab4e66..d85e6be9 100644 --- a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs +++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs @@ -44,5 +44,45 @@ public int MaxRedirect /// A boolean value to determine if we redirects are allowed if the scheme changes(e.g. https to http). Defaults to false. /// public bool AllowRedirectOnSchemeChange { get; set; } = false; + + /// + /// A callback that is invoked to scrub sensitive headers from the request before following a redirect. + /// This callback receives the request being modified, the original URI, the new redirect URI, and a proxy resolver function. + /// The proxy resolver returns the proxy URI for a given destination, or null if no proxy applies. + /// Defaults to . + /// + public Action?> ScrubSensitiveHeaders { get; set; } = DefaultScrubSensitiveHeaders; + + /// + /// The default implementation for scrubbing sensitive headers during redirects. + /// This method removes Authorization and Cookie headers when the host or scheme changes, + /// and removes ProxyAuthorization headers when no proxy is configured or the proxy is bypassed for the new URI. + /// + /// The HTTP request message to modify. + /// The original request URI. + /// The new redirect URI. + /// A function that returns the proxy URI for a destination, or null if no proxy applies. Can be null if no proxy is configured. + public static void DefaultScrubSensitiveHeaders(HttpRequestMessage request, Uri originalUri, Uri newUri, Func? proxyResolver) + { + if(request == null) throw new ArgumentNullException(nameof(request)); + if(originalUri == null) throw new ArgumentNullException(nameof(originalUri)); + if(newUri == null) throw new ArgumentNullException(nameof(newUri)); + + // Remove Authorization and Cookie headers if http request's scheme or host changes + var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || + !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); + if(isDifferentHostOrScheme) + { + request.Headers.Authorization = null; + request.Headers.Remove("Cookie"); + } + + // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed + var isProxyInactive = proxyResolver == null || proxyResolver(newUri) == null; + if(isProxyInactive) + { + request.Headers.ProxyAuthorization = null; + } + } } } diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 2c4d37f4..b6bf83ed 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -119,12 +119,9 @@ protected override async Task SendAsync(HttpRequestMessage newRequest.RequestUri = new Uri(baseAddress + response.Headers.Location); } - // Remove Auth if http request's scheme or host changes - if(!newRequest.RequestUri.Host.Equals(request.RequestUri?.Host) || - !newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme)) - { - newRequest.Headers.Authorization = null; - } + // Scrub sensitive headers before following the redirect + var proxyResolver = GetProxyResolver(); + redirectOption.ScrubSensitiveHeaders(newRequest, request.RequestUri!, newRequest.RequestUri, proxyResolver); // If scheme has changed. Ensure that this has been opted in for security reasons if(!newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme) && !redirectOption.AllowRedirectOnSchemeChange) @@ -183,5 +180,48 @@ private static bool IsRedirect(HttpStatusCode statusCode) }; } + /// + /// Gets a callback that resolves the proxy URI for a given destination URI. + /// + /// A function that takes a destination URI and returns the proxy URI, or null if no proxy is configured or the destination is bypassed. + private Func? GetProxyResolver() + { + var proxy = GetProxyFromFinalHandler(); + if(proxy == null) + return null; + return destination => proxy.IsBypassed(destination) ? null : proxy.GetProxy(destination); + } + + /// + /// Traverses the handler chain to find the final handler and extract its proxy settings. + /// + /// The IWebProxy from the final handler, or null if not found. + private IWebProxy? GetProxyFromFinalHandler() + { +#if BROWSER + // Browser platform does not support proxy configuration + return null; +#else + var handler = InnerHandler; + while(handler != null) + { +#if NETFRAMEWORK + if(handler is WinHttpHandler winHttpHandler) + return winHttpHandler.Proxy; +#endif +#if NET5_0_OR_GREATER + if (handler is SocketsHttpHandler socketsHandler) + return socketsHandler.Proxy; +#endif + if(handler is HttpClientHandler httpClientHandler) + return httpClientHandler.Proxy; + if(handler is DelegatingHandler delegatingHandler) + handler = delegatingHandler.InnerHandler; + else + break; + } + return null; +#endif + } } } diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index e7a8d703..3a73bd03 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -11,6 +12,63 @@ namespace Microsoft.Kiota.Http.HttpClientLibrary.Tests.Middleware { + /// + /// A mock IWebProxy implementation for testing proxy bypass scenarios. + /// + internal class MockWebProxy : IWebProxy + { + private readonly Uri _proxyUri; + private readonly string[] _bypassList; + + public MockWebProxy(Uri proxyUri, params string[] bypassList) + { + _proxyUri = proxyUri; + _bypassList = bypassList; + } + + public ICredentials? Credentials { get; set; } + + public Uri? GetProxy(Uri destination) => _proxyUri; + + public bool IsBypassed(Uri host) + { + return _bypassList.Any(bypass => host.Host.IndexOf(bypass, StringComparison.OrdinalIgnoreCase) >= 0); + } + } + + /// + /// A mock DelegatingHandler for testing that allows setting responses and proper chaining. + /// + internal class MockDelegatingRedirectHandler : DelegatingHandler + { + private HttpResponseMessage? _response1; + private HttpResponseMessage? _response2; + private bool _response1Sent; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if(!_response1Sent) + { + _response1Sent = true; + _response1!.RequestMessage = request; + return Task.FromResult(_response1); + } + else + { + _response1Sent = false; + _response2!.RequestMessage = request; + return Task.FromResult(_response2); + } + } + + public void SetHttpResponse(HttpResponseMessage? response1, HttpResponseMessage? response2 = null) + { + _response1Sent = false; + _response1 = response1; + _response2 = response2; + } + } + public sealed class RedirectHandlerTests : IDisposable { private readonly MockRedirectHandler _testHttpMessageHandler; @@ -70,15 +128,17 @@ public void RedirectHandler_RedirectOptionConstructor() [Fact] public async Task OkStatusShouldPassThrough() { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); - var redirectResponse = new HttpResponseMessage(HttpStatusCode.OK); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse); // sets the mock response - // Act - var response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Same(response.RequestMessage, httpRequestMessage); + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + { + // Arrange + var redirectResponse = new HttpResponseMessage(HttpStatusCode.OK); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse); // sets the mock response + // Act + var response = await this._invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Same(response.RequestMessage, httpRequestMessage); + } } [Theory] @@ -88,88 +148,559 @@ public async Task OkStatusShouldPassThrough() [InlineData((HttpStatusCode)308)] // 308 not available in netstandard public async Task ShouldRedirectSameMethodAndContent(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange + httpRequestMessage.Content = new StringContent("Hello World"); + + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.Equal(response.RequestMessage?.Method, httpRequestMessage.Method); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotNull(response.RequestMessage?.Content); + Assert.Equal("Hello World", await response.RequestMessage.Content.ReadAsStringAsync( +#if NET5_0_OR_GREATER + TestContext.Current.CancellationToken +#endif + )); + } + } + + [Fact] + public async Task ShouldRedirectChangeMethodAndContent() + { + + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange + httpRequestMessage.Content = new StringContent("Hello World"); + + var redirectResponse = new HttpResponseMessage(HttpStatusCode.SeeOther); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotEqual(response.RequestMessage?.Method, httpRequestMessage.Method); + Assert.Equal(response.RequestMessage?.Method, HttpMethod.Get); + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Null(response.RequestMessage?.Content); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode statusCode) + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + { + // Arrange + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.Null(response.RequestMessage?.Headers.Authorization); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAllowRedirectOnSchemeChangeIsDisabled(HttpStatusCode statusCode) + { + + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + { + // Arrange + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync(httpRequestMessage, CancellationToken.None)); + // Assert + Assert.Contains("Redirects with changing schemes not allowed by default", exception.Message); + Assert.Equal("Scheme changed from https to http.", exception.InnerException?.Message); + Assert.IsType(exception); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { - Content = new StringContent("Hello World") + // Arrange + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._redirectHandler.RedirectOption.AllowRedirectOnSchemeChange = true;// Enable redirects on scheme change + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); + Assert.Null(response.RequestMessage?.Headers.Authorization); + } + } + + [Fact] + public async Task RedirectWithSameHostShouldKeepAuthHeader() + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.NotNull(response.RequestMessage?.Headers.Authorization); + } + } + + [Fact] + public async Task RedirectWithRelativeUrlShouldKeepRequestHost() + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { // Arrange + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("/bar", UriKind.Relative); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal("http://example.org/bar", response.RequestMessage?.RequestUri?.AbsoluteUri); + } + } + + [Fact] + public async Task ExceedMaxRedirectsShouldThrowsException() + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange + var response1 = new HttpResponseMessage(HttpStatusCode.Redirect); + response1.Headers.Location = new Uri("http://example.org/bar"); + var response2 = new HttpResponseMessage(HttpStatusCode.Redirect); + response2.Headers.Location = new Uri("http://example.org/foo"); + this._testHttpMessageHandler.SetHttpResponse(response1, response2);// sets the mock response + // Act + var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync( + httpRequestMessage, CancellationToken.None)); + // Assert + Assert.Equal("Too many redirects performed", exception.Message); + Assert.Equal("Max redirects exceeded. Redirect count : 5", exception.InnerException?.Message); + Assert.IsType(exception); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + { + // Arrange - No proxy is configured, so ProxyAuthorization should be removed + httpRequestMessage.Headers.ProxyAuthorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert - ProxyAuthorization is removed when no proxy is configured + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCode statusCode) + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + { + // Arrange + httpRequestMessage.Headers.Add("Cookie", "session=abc123"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.False(response.RequestMessage?.Headers.Contains("Cookie")); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentHostShouldRemoveCustomHeadersWithCustomCallback(HttpStatusCode statusCode) + { + // Arrange - Custom callback that removes additional headers on host change + var redirectOption = new RedirectHandlerOption + { + ScrubSensitiveHeaders = (request, originalUri, newUri, proxyResolver) => + { + // Call default implementation first + RedirectHandlerOption.DefaultScrubSensitiveHeaders(request, originalUri, newUri, proxyResolver); + + // Then add custom logic for additional headers + var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || + !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); + if(isDifferentHostOrScheme) + { + request.Headers.Remove("X-Api-Key"); + request.Headers.Remove("X-Custom-Auth"); + } + } + }; + var mockHandler = new MockRedirectHandler(); + var redirectHandler = new RedirectHandler(redirectOption) + { + InnerHandler = mockHandler }; + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.Add("X-Api-Key", "secret-api-key"); + httpRequestMessage.Headers.Add("X-Custom-Auth", "custom-auth-value"); + httpRequestMessage.Headers.Add("X-Safe-Header", "should-remain"); + var redirectResponse = new HttpResponseMessage(statusCode); - redirectResponse.Headers.Location = new Uri("http://example.org/bar"); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + // Act - var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert - Assert.Equal(response.RequestMessage?.Method, httpRequestMessage.Method); + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - Custom headers should be removed, but non-sensitive should remain Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.NotNull(response.RequestMessage?.Content); - Assert.Equal("Hello World", await response.RequestMessage.Content.ReadAsStringAsync( -#if NET5_0_OR_GREATER - TestContext.Current.CancellationToken -#endif - )); + Assert.False(response.RequestMessage?.Headers.Contains("X-Api-Key")); + Assert.False(response.RequestMessage?.Headers.Contains("X-Custom-Auth")); + Assert.True(response.RequestMessage?.Headers.Contains("X-Safe-Header")); + } + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeShouldRemoveCustomHeadersWithCustomCallback(HttpStatusCode statusCode) + { + // Arrange - Custom callback that removes additional headers on scheme change + var redirectOption = new RedirectHandlerOption + { + AllowRedirectOnSchemeChange = true, + ScrubSensitiveHeaders = (request, originalUri, newUri, proxyResolver) => + { + RedirectHandlerOption.DefaultScrubSensitiveHeaders(request, originalUri, newUri, proxyResolver); + + var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || + !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); + if(isDifferentHostOrScheme) + { + request.Headers.Remove("X-Api-Key"); + } + } + }; + var mockHandler = new MockRedirectHandler(); + var redirectHandler = new RedirectHandler(redirectOption) + { + InnerHandler = mockHandler + }; + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); + httpRequestMessage.Headers.Add("X-Api-Key", "secret-api-key"); + + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - Custom headers should be removed on scheme change + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.False(response.RequestMessage?.Headers.Contains("X-Api-Key")); } [Fact] - public async Task ShouldRedirectChangeMethodAndContent() + public async Task RedirectWithSameHostAndSchemeCustomCallbackShouldKeepCustomHeaders() { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo") + // Arrange - Custom callback that only removes headers on host/scheme change + var redirectOption = new RedirectHandlerOption { - Content = new StringContent("Hello World") + ScrubSensitiveHeaders = (request, originalUri, newUri, proxyResolver) => + { + RedirectHandlerOption.DefaultScrubSensitiveHeaders(request, originalUri, newUri, proxyResolver); + + var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || + !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); + if(isDifferentHostOrScheme) + { + request.Headers.Remove("X-Api-Key"); + } + } }; - var redirectResponse = new HttpResponseMessage(HttpStatusCode.SeeOther); - redirectResponse.Headers.Location = new Uri("http://example.org/bar"); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + var mockHandler = new MockRedirectHandler(); + var redirectHandler = new RedirectHandler(redirectOption) + { + InnerHandler = mockHandler + }; + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.Add("X-Api-Key", "secret-api-key"); + + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); // Same host and scheme + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + // Act - var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert - Assert.NotEqual(response.RequestMessage?.Method, httpRequestMessage.Method); - Assert.Equal(response.RequestMessage?.Method, HttpMethod.Get); + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - Custom headers should be kept when host and scheme are the same Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.Null(response.RequestMessage?.Content); + Assert.True(response.RequestMessage?.Headers.Contains("X-Api-Key")); + } + + [Fact] + public async Task DefaultScrubSensitiveHeadersMethodCanBeReferencedDirectly() + { + // Arrange - Verify that the default static method can be referenced and used + var redirectOption = new RedirectHandlerOption + { + // Explicitly set to default to verify it's accessible + ScrubSensitiveHeaders = RedirectHandlerOption.DefaultScrubSensitiveHeaders + }; + var mockHandler = new MockRedirectHandler(); + var redirectHandler = new RedirectHandler(redirectOption) + { + InnerHandler = mockHandler + }; + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token"); + + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - Default behavior should remove Authorization on host change + Assert.Null(response.RequestMessage?.Headers.Authorization); } +#if !BROWSER [Theory] [InlineData(HttpStatusCode.MovedPermanently)] // 301 [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode statusCode) + public async Task CustomCallbackCanUseProxyResolverToKeepHeadersWhenProxyActive(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); - httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + // Arrange - Custom callback that only removes custom headers when proxy is inactive + var mockProxy = new MockWebProxy(new Uri("http://proxy.example.com:8080")); // no bypasses + var httpClientHandler = new HttpClientHandler { Proxy = mockProxy }; + var mockHandler = new MockDelegatingRedirectHandler + { + InnerHandler = httpClientHandler + }; + var redirectOption = new RedirectHandlerOption + { + ScrubSensitiveHeaders = (request, originalUri, newUri, proxyResolver) => + { + // Call default implementation + RedirectHandlerOption.DefaultScrubSensitiveHeaders(request, originalUri, newUri, proxyResolver); + + // Only remove X-Api-Key if proxy is inactive (like ProxyAuthorization logic) + var isProxyInactive = proxyResolver == null || proxyResolver(newUri) == null; + var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || + !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); + if(isProxyInactive && isDifferentHostOrScheme) + { + request.Headers.Remove("X-Api-Key"); + } + } + }; + var redirectHandler = new RedirectHandler(redirectOption) + { + InnerHandler = mockHandler + }; + + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.Add("X-Api-Key", "secret-api-key"); + var redirectResponse = new HttpResponseMessage(statusCode); - redirectResponse.Headers.Location = new Uri("http://example.net/bar"); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + redirectResponse.Headers.Location = new Uri("http://example.net/bar"); // different host, but proxy is active + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + // Act - var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - X-Api-Key should be kept because proxy is active Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); - Assert.Null(response.RequestMessage?.Headers.Authorization); + Assert.True(response.RequestMessage?.Headers.Contains("X-Api-Key")); } +#endif [Theory] [InlineData(HttpStatusCode.MovedPermanently)] // 301 [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAllowRedirectOnSchemeChangeIsDisabled(HttpStatusCode statusCode) + public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + { + // Arrange - No proxy is configured, so ProxyAuthorization should be removed + httpRequestMessage.Headers.ProxyAuthorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._redirectHandler.RedirectOption.AllowRedirectOnSchemeChange = true;// Enable redirects on scheme change + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert - ProxyAuthorization is removed when no proxy is configured + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + } + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); - httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + { + // Arrange + httpRequestMessage.Headers.Add("Cookie", "session=abc123"); + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._redirectHandler.RedirectOption.AllowRedirectOnSchemeChange = true;// Enable redirects on scheme change + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); + Assert.False(response.RequestMessage?.Headers.Contains("Cookie")); + } + } + + [Fact] + public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured() + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange - No proxy is configured, so ProxyAuthorization should be removed + httpRequestMessage.Headers.ProxyAuthorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert - ProxyAuthorization is removed when no proxy is configured + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + } + } + + [Fact] + public async Task RedirectWithSameHostShouldKeepCookieHeader() + { + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange + httpRequestMessage.Headers.Add("Cookie", "session=abc123"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + // Act + var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); + // Assert + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.True(response.RequestMessage?.Headers.Contains("Cookie")); + } + } + +#if !BROWSER + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectToBypassedProxyUrlShouldRemoveProxyAuthHeader(HttpStatusCode statusCode) + { + // Arrange - Create a handler chain with a proxy that bypasses "internal.local" + var mockProxy = new MockWebProxy(new Uri("http://proxy.example.com:8080"), "internal.local"); + var httpClientHandler = new HttpClientHandler { Proxy = mockProxy }; + var mockHandler = new MockDelegatingRedirectHandler + { + InnerHandler = httpClientHandler + }; + var redirectHandler = new RedirectHandler + { + InnerHandler = mockHandler + }; + + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.ProxyAuthorization = new AuthenticationHeaderValue("Basic", "creds"); + var redirectResponse = new HttpResponseMessage(statusCode); - redirectResponse.Headers.Location = new Uri("http://example.org/bar"); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + redirectResponse.Headers.Location = new Uri("http://internal.local/bar"); // Bypassed by proxy + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + // Act - var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync(httpRequestMessage, CancellationToken.None)); - // Assert - Assert.Contains("Redirects with changing schemes not allowed by default", exception.Message); - Assert.Equal("Scheme changed from https to http.", exception.InnerException?.Message); - Assert.IsType(exception); + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - ProxyAuthorization should be removed because internal.local is bypassed + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); } [Theory] @@ -177,72 +708,128 @@ public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAl [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) + public async Task RedirectToProxiedUrlShouldKeepProxyAuthHeader(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); - httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + // Arrange - Create a handler chain with a proxy that bypasses "internal.local" + var mockProxy = new MockWebProxy(new Uri("http://proxy.example.com:8080"), "internal.local"); + var httpClientHandler = new HttpClientHandler { Proxy = mockProxy }; + var mockHandler = new MockDelegatingRedirectHandler + { + InnerHandler = httpClientHandler + }; + var redirectHandler = new RedirectHandler + { + InnerHandler = mockHandler + }; + + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.ProxyAuthorization = new AuthenticationHeaderValue("Basic", "creds"); + var redirectResponse = new HttpResponseMessage(statusCode); - redirectResponse.Headers.Location = new Uri("http://example.org/bar"); - this._redirectHandler.RedirectOption.AllowRedirectOnSchemeChange = true;// Enable redirects on scheme change - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); // NOT bypassed, requires proxy + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + // Act - var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - ProxyAuthorization should be kept because example.org requires proxy Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); - Assert.Null(response.RequestMessage?.Headers.Authorization); + Assert.NotNull(response.RequestMessage?.Headers.ProxyAuthorization); } [Fact] - public async Task RedirectWithSameHostShouldKeepAuthHeader() + public async Task RedirectWithNoProxyConfiguredShouldRemoveProxyAuthHeader() { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); - httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); + // Arrange - Create a handler chain without a proxy configured + var httpClientHandler = new HttpClientHandler { Proxy = null }; + var mockHandler = new MockDelegatingRedirectHandler + { + InnerHandler = httpClientHandler + }; + var redirectHandler = new RedirectHandler + { + InnerHandler = mockHandler + }; + + using var invoker = new HttpMessageInvoker(redirectHandler); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + httpRequestMessage.Headers.ProxyAuthorization = new AuthenticationHeaderValue("Basic", "creds"); + var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); redirectResponse.Headers.Location = new Uri("http://example.org/bar"); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + // Act - var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - ProxyAuthorization should be removed when no proxy is configured Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); - Assert.NotNull(response.RequestMessage?.Headers.Authorization); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); } +#endif - [Fact] - public async Task RedirectWithRelativeUrlShouldKeepRequestHost() + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectResponseWithoutLocationHeaderShouldThrow(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); - var redirectResponse = new HttpResponseMessage(HttpStatusCode.Redirect); - redirectResponse.Headers.Location = new Uri("/bar", UriKind.Relative); - this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + // Arrange - redirect response with no Location header set + var redirectResponse = new HttpResponseMessage(statusCode); // Location intentionally omitted + this._testHttpMessageHandler.SetHttpResponse(redirectResponse); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => this._invoker.SendAsync(httpRequestMessage, CancellationToken.None)); + Assert.Contains("Unable to perform redirect as Location Header is not set in response", exception.Message); + Assert.Contains(statusCode.ToString(), exception.InnerException?.Message); + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task ShouldNotRedirectWhenShouldRedirectDelegateReturnsFalse(HttpStatusCode statusCode) + { + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + // Arrange - ShouldRedirect delegate always returns false + this._redirectHandler.RedirectOption.ShouldRedirect = _ => false; + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse); + // Act - var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert - Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.Equal("http://example.org/bar", response.RequestMessage?.RequestUri?.AbsoluteUri); + var response = await this._invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - response is returned as-is without following the redirect + Assert.Equal(statusCode, response.StatusCode); + Assert.Same(response.RequestMessage, httpRequestMessage); } - [Fact] - public async Task ExceedMaxRedirectsShouldThrowsException() + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task ShouldNotRedirectWhenMaxRedirectIsZero(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); - var response1 = new HttpResponseMessage(HttpStatusCode.Redirect); - response1.Headers.Location = new Uri("http://example.org/bar"); - var response2 = new HttpResponseMessage(HttpStatusCode.Redirect); - response2.Headers.Location = new Uri("http://example.org/foo"); - this._testHttpMessageHandler.SetHttpResponse(response1, response2);// sets the mock response + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + // Arrange - MaxRedirect = 0 disables redirects entirely + this._redirectHandler.RedirectOption.MaxRedirect = 0; + var redirectResponse = new HttpResponseMessage(statusCode); + redirectResponse.Headers.Location = new Uri("http://example.org/bar"); + this._testHttpMessageHandler.SetHttpResponse(redirectResponse); + // Act - var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync( - httpRequestMessage, CancellationToken.None)); - // Assert - Assert.Equal("Too many redirects performed", exception.Message); - Assert.Equal("Max redirects exceeded. Redirect count : 5", exception.InnerException?.Message); - Assert.IsType(exception); + var response = await this._invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - response is returned as-is without following the redirect + Assert.Equal(statusCode, response.StatusCode); + Assert.Same(response.RequestMessage, httpRequestMessage); } } }