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