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