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..3acb02d9 100644
--- a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs
+++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs
@@ -3,6 +3,7 @@
// ------------------------------------------------------------------------------
using System;
+using System.Collections.Generic;
using System.Net.Http;
using Microsoft.Kiota.Abstractions;
@@ -44,5 +45,12 @@ 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 collection of header names that should be removed when the host or scheme changes during a redirect.
+ /// This is useful for removing sensitive headers like API keys that should not be sent to different hosts.
+ /// The Authorization and Cookie headers are always removed on host/scheme change regardless of this setting.
+ ///
+ public ICollection SensitiveHeaders { get; set; } = new List();
}
}
diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs
index 2c4d37f4..7d477597 100644
--- a/src/http/httpClient/Middleware/RedirectHandler.cs
+++ b/src/http/httpClient/Middleware/RedirectHandler.cs
@@ -2,14 +2,14 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information.
// ------------------------------------------------------------------------------
+using Microsoft.Kiota.Http.HttpClientLibrary.Extensions;
+using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;
using System;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.Kiota.Http.HttpClientLibrary.Extensions;
-using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;
namespace Microsoft.Kiota.Http.HttpClientLibrary.Middleware
{
@@ -119,11 +119,28 @@ protected override async Task SendAsync(HttpRequestMessage
newRequest.RequestUri = new Uri(baseAddress + response.Headers.Location);
}
- // Remove Auth if http request's scheme or host changes
+ // Remove Authorization and Cookie header 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;
+ newRequest.Headers.Remove("Cookie");
+
+ // Remove any additional sensitive headers configured in the options
+ if(redirectOption.SensitiveHeaders.Count > 0)
+ {
+ foreach(var header in redirectOption.SensitiveHeaders)
+ {
+ newRequest.Headers.Remove(header);
+ }
+ }
+ }
+
+ // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed
+ var proxyResolver = GetProxyResolver();
+ if(proxyResolver == null || proxyResolver(newRequest.RequestUri) == null)
+ {
+ newRequest.Headers.ProxyAuthorization = null;
}
// If scheme has changed. Ensure that this has been opted in for security reasons
@@ -183,5 +200,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 3208076c..3b06a850 100644
--- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs
+++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs
@@ -4,6 +4,7 @@
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
+using System.Linq;
using Microsoft.Kiota.Http.HttpClientLibrary.Middleware;
using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;
using Microsoft.Kiota.Http.HttpClientLibrary.Tests.Mocks;
@@ -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.Contains(bypass, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ ///
+ /// 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,42 +148,44 @@ 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"))
{
- 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());
+ // 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());
+ }
}
[Fact]
public async Task ShouldRedirectChangeMethodAndContent()
{
- // Arrange
- var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")
+
+ using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"))
{
- 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);
+ // 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]
@@ -133,18 +195,20 @@ public async Task ShouldRedirectChangeMethodAndContent()
[InlineData((HttpStatusCode)308)] // 308
public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode statusCode)
{
- // Arrange
- var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo");
- 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);
+ 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]
@@ -154,18 +218,184 @@ public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode
[InlineData((HttpStatusCode)308)] // 308
public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAllowRedirectOnSchemeChangeIsDisabled(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.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"))
+ {
+ // 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 RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStatusCode statusCode)
+ {
+ // Arrange - Configure sensitive headers to be removed on host change
+ var redirectOption = new RedirectHandlerOption
+ {
+ SensitiveHeaders = { "X-Api-Key", "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 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 - Sensitive headers should be removed, but non-sensitive should remain
+ Assert.NotSame(response.RequestMessage, httpRequestMessage);
+ 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]
@@ -173,72 +403,250 @@ 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 RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpStatusCode statusCode)
{
- // Arrange
- var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo");
- httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam");
+ // Arrange - Configure sensitive headers to be removed on scheme change
+ var redirectOption = new RedirectHandlerOption
+ {
+ AllowRedirectOnSchemeChange = true,
+ SensitiveHeaders = { "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");
- this._redirectHandler.RedirectOption.AllowRedirectOnSchemeChange = true;// Enable redirects on scheme change
- 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 - Sensitive headers should be removed on scheme change
Assert.NotSame(response.RequestMessage, httpRequestMessage);
- Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme);
- Assert.Null(response.RequestMessage?.Headers.Authorization);
+ Assert.False(response.RequestMessage?.Headers.Contains("X-Api-Key"));
}
[Fact]
- public async Task RedirectWithSameHostShouldKeepAuthHeader()
+ public async Task RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders()
{
- // Arrange
- var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo");
- httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam");
+ // Arrange - Configure sensitive headers
+ var redirectOption = new RedirectHandlerOption
+ {
+ SensitiveHeaders = { "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, "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");
- this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response
+ 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
+ var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None);
+
+ // Assert - Sensitive headers should be kept when host and scheme are the same
Assert.NotSame(response.RequestMessage, httpRequestMessage);
- Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host);
- Assert.NotNull(response.RequestMessage?.Headers.Authorization);
+ Assert.True(response.RequestMessage?.Headers.Contains("X-Api-Key"));
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.MovedPermanently)] // 301
+ [InlineData(HttpStatusCode.Found)] // 302
+ [InlineData(HttpStatusCode.TemporaryRedirect)] // 307
+ [InlineData((HttpStatusCode)308)] // 308
+ 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)
+ {
+ 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 RedirectWithRelativeUrlShouldKeepRequestHost()
+ public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured()
{
- // 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.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://internal.local/bar"); // Bypassed by 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 removed because internal.local is bypassed
Assert.NotSame(response.RequestMessage, httpRequestMessage);
- Assert.Equal("http://example.org/bar", response.RequestMessage?.RequestUri?.AbsoluteUri);
+ 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 RedirectToProxiedUrlShouldKeepProxyAuthHeader(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"); // NOT bypassed, requires proxy
+ mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));
+
+ // Act
+ var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None);
+
+ // Assert - ProxyAuthorization should be kept because example.org requires proxy
+ Assert.NotSame(response.RequestMessage, httpRequestMessage);
+ Assert.NotNull(response.RequestMessage?.Headers.ProxyAuthorization);
}
[Fact]
- public async Task ExceedMaxRedirectsShouldThrowsException()
+ public async Task RedirectWithNoProxyConfiguredShouldRemoveProxyAuthHeader()
{
- // 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
+ // 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");
+ mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));
+
// 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 invoker.SendAsync(httpRequestMessage, CancellationToken.None);
+
+ // Assert - ProxyAuthorization should be removed when no proxy is configured
+ Assert.NotSame(response.RequestMessage, httpRequestMessage);
+ Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization);
}
+#endif
}
}