From e6a2654ee2eb21f7270a7ad2057d52832a030c36 Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:51:13 -0800 Subject: [PATCH 01/22] fix: remove proxy and cookie auth headers on insecure redirect --- .vscode/settings.json | 3 +- .../httpClient/Middleware/RedirectHandler.cs | 2 + .../Middleware/RedirectHandlerTests.cs | 120 ++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) 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/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 2c4d37f4..9317483e 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -124,6 +124,8 @@ protected override async Task SendAsync(HttpRequestMessage !newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme)) { newRequest.Headers.Authorization = null; + newRequest.Headers.ProxyAuthorization = null; + newRequest.Headers.Remove("Cookie"); } // If scheme has changed. Ensure that this has been opted in for security reasons diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 3208076c..f215f4ef 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -240,5 +240,125 @@ public async Task ExceedMaxRedirectsShouldThrowsException() 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 RedirectWithDifferentHostShouldRemoveProxyAuthHeader(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + 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 + 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) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); + 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 RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); + 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 + 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.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 RedirectWithSameHostShouldKeepProxyAuthHeader() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); + 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 + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.NotNull(response.RequestMessage?.Headers.ProxyAuthorization); + } + + [Fact] + public async Task RedirectWithSameHostShouldKeepCookieHeader() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); + 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")); + } } } From fa484545c7f867308d530319e3f24f312c3bd32f Mon Sep 17 00:00:00 2001 From: Michael Mainer <8527305+MIchaelMainer@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:49:30 -0800 Subject: [PATCH 02/22] fix: dispose of HttpRequestMessage instance Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Middleware/RedirectHandlerTests.cs | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index f215f4ef..03b3acbe 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -270,17 +270,19 @@ public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeader(HttpStatu public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCode statusCode) { // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); - 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")); + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + { + 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] From a883463d8ca81d5f46f3a32cf2c92b9d0c4307d3 Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:02:40 -0800 Subject: [PATCH 03/22] test: update tests to dispose HttpRequestMessage --- .../Middleware/RedirectHandlerTests.cs | 372 ++++++++++-------- 1 file changed, 199 insertions(+), 173 deletions(-) diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 03b3acbe..e04eac91 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -70,15 +70,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 +90,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 +137,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 +160,21 @@ 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"); - 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); + + 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] @@ -175,70 +184,77 @@ public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAl [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); - 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); + 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() { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); - 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); + 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() { - // 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 - // 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); + 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() { - // 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 - // Act - var exception = await Assert.ThrowsAsync(() => this._invoker.SendAsync( + 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); + // Assert + Assert.Equal("Too many redirects performed", exception.Message); + Assert.Equal("Max redirects exceeded. Redirect count : 5", exception.InnerException?.Message); + Assert.IsType(exception); + } } [Theory] @@ -248,18 +264,20 @@ public async Task ExceedMaxRedirectsShouldThrowsException() [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeader(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo"); - 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 - Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); - Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + { + // Arrange + 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 + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + } } [Theory] @@ -269,9 +287,9 @@ public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeader(HttpStatu [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCode statusCode) { - // Arrange - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + 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"); @@ -292,19 +310,21 @@ public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCo [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); - 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 - Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); - Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + { + // Arrange + 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 + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.NotSame(response.RequestMessage?.RequestUri?.Scheme, httpRequestMessage.RequestUri?.Scheme); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); + } } [Theory] @@ -314,53 +334,59 @@ public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderIfAllowR [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo"); - 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")); + 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 RedirectWithSameHostShouldKeepProxyAuthHeader() { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); - 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 - Assert.NotSame(response.RequestMessage, httpRequestMessage); - Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); - Assert.NotNull(response.RequestMessage?.Headers.ProxyAuthorization); + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + { + // Arrange + 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 + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.Equal(response.RequestMessage?.RequestUri?.Host, httpRequestMessage.RequestUri?.Host); + Assert.NotNull(response.RequestMessage?.Headers.ProxyAuthorization); + } } [Fact] public async Task RedirectWithSameHostShouldKeepCookieHeader() { - // Arrange - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo"); - 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")); + 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")); + } } } } From 088f69fcdc1e4661d801f0d339bae5cbc8e932e5 Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:41:59 -0800 Subject: [PATCH 04/22] fix 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.ProxyAuthorization = null; newRequest.Headers.Remove("Cookie"); } + // 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 if(!newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme) && !redirectOption.AllowRedirectOnSchemeChange) { @@ -185,5 +191,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 e04eac91..8709e0a6 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -11,6 +11,68 @@ 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) + { + foreach(var bypass in _bypassList) + { + if(host.Host.Contains(bypass, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + } + + /// + /// 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; @@ -262,18 +324,18 @@ public async Task ExceedMaxRedirectsShouldThrowsException() [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeader(HttpStatusCode statusCode) + public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) { using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { - // Arrange + // 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 + // 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); @@ -308,11 +370,11 @@ public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCo [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) + public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) { using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { - // Arrange + // 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"); @@ -320,7 +382,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderIfAllowR this._testHttpMessageHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK));// sets the mock response // Act var response = await _invoker.SendAsync(httpRequestMessage, new CancellationToken()); - // Assert + // 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); @@ -352,21 +414,21 @@ public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedi } [Fact] - public async Task RedirectWithSameHostShouldKeepProxyAuthHeader() + public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured() { using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { - // Arrange + // 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 + // Assert - ProxyAuthorization is 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.ProxyAuthorization); + Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); } } @@ -388,5 +450,107 @@ public async Task RedirectWithSameHostShouldKeepCookieHeader() 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, CancellationToken.None); + + // Assert - ProxyAuthorization should be removed because internal.local is bypassed + Assert.NotSame(response.RequestMessage, httpRequestMessage); + 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 RedirectWithNoProxyConfiguredShouldRemoveProxyAuthHeader() + { + // 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 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 } } From 7e8e746250490c418dfd32432deb38ad7e375dd7 Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:04:11 -0800 Subject: [PATCH 05/22] fix(security): enable configuration to allow removing arbitrary header on redirect This is useful for scenarios like API keys --- .../Options/RedirectHandlerOption.cs | 8 ++ .../httpClient/Middleware/RedirectHandler.cs | 9 ++ .../Middleware/RedirectHandlerTests.cs | 100 ++++++++++++++++++ 3 files changed, 117 insertions(+) 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 280047bf..f80223ed 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -125,6 +125,15 @@ protected override async Task SendAsync(HttpRequestMessage { 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 diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 8709e0a6..01b4b80b 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -365,6 +365,106 @@ public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCo } } + [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.net/bar"); + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + 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] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) + { + // 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"); + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - Sensitive 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 RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() + { + // 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"); // Same host and scheme + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + 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.True(response.RequestMessage?.Headers.Contains("X-Api-Key")); + } + [Theory] [InlineData(HttpStatusCode.MovedPermanently)] // 301 [InlineData(HttpStatusCode.Found)] // 302 From 9ca26d68775487b69901787a743a7cabbb59fdee Mon Sep 17 00:00:00 2001 From: Michael Mainer <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:32:30 -0800 Subject: [PATCH 06/22] test(redirecthandlertests): use a more concise statement Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/http/httpClient/Middleware/RedirectHandlerTests.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 01b4b80b..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; @@ -31,12 +32,7 @@ public MockWebProxy(Uri proxyUri, params string[] bypassList) public bool IsBypassed(Uri host) { - foreach(var bypass in _bypassList) - { - if(host.Host.Contains(bypass, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; + return _bypassList.Any(bypass => host.Host.Contains(bypass, StringComparison.OrdinalIgnoreCase)); } } From c066d522e2ab4cef6b6c1ea1572e0ee93da45471 Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:46:50 -0800 Subject: [PATCH 07/22] test: fix using directive order --- src/http/httpClient/Middleware/RedirectHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index f80223ed..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 { From 0bba6b422c7861171b6c5c2f626eaf94638c0dfe Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 24 Feb 2026 15:20:32 -0600 Subject: [PATCH 08/22] Fixing linter errors --- src/http/httpClient/Middleware/RedirectHandler.cs | 4 ++-- tests/http/httpClient/Middleware/RedirectHandlerTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 7d477597..f80223ed 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 { diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 3b06a850..e72e4aa4 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -1,10 +1,10 @@ using System; +using System.Linq; using System.Net; using System.Net.Http; 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; From 6de56ebacdbc5a698de40a8cc6119bfcb1e73a89 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 24 Feb 2026 15:40:27 -0600 Subject: [PATCH 09/22] Testing fix for build error --- tests/http/httpClient/Middleware/RedirectHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index e72e4aa4..6add4110 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -32,7 +32,7 @@ public MockWebProxy(Uri proxyUri, params string[] bypassList) public bool IsBypassed(Uri host) { - return _bypassList.Any(bypass => host.Host.Contains(bypass, StringComparison.OrdinalIgnoreCase)); + return _bypassList.Any(bypass => host.Host.IndexOf(bypass, StringComparison.OrdinalIgnoreCase) >= 0); } } From 075bb1599488154241157920dca8ce72b7b085f1 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 25 Feb 2026 09:55:39 -0600 Subject: [PATCH 10/22] code review changes --- .../Options/RedirectHandlerOption.cs | 8 +-- .../httpClient/Middleware/RedirectHandler.cs | 17 +++--- .../Middleware/RedirectHandlerTests.cs | 52 ++++++++++++++++--- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs index 3acb02d9..031dde14 100644 --- a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs +++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs @@ -3,7 +3,6 @@ // ------------------------------------------------------------------------------ using System; -using System.Collections.Generic; using System.Net.Http; using Microsoft.Kiota.Abstractions; @@ -47,10 +46,11 @@ public int MaxRedirect 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. + /// An optional callback invoked for each request header on every redirect to determine whether the header + /// should be removed. Receives the header name, the new origin URI, and the original origin URI (which may + /// be null). Return true to strip the header from the redirected request. /// The Authorization and Cookie headers are always removed on host/scheme change regardless of this setting. /// - public ICollection SensitiveHeaders { get; set; } = new List(); + public Func? ShouldRemoveHeader { get; set; } } } diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index f80223ed..9d990f1b 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------------ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Http; @@ -125,15 +126,19 @@ protected override async Task SendAsync(HttpRequestMessage { newRequest.Headers.Authorization = null; newRequest.Headers.Remove("Cookie"); + } - // Remove any additional sensitive headers configured in the options - if(redirectOption.SensitiveHeaders.Count > 0) + // Invoke the callback for every header to allow callers to strip additional headers + if(redirectOption.ShouldRemoveHeader != null) + { + var headersToRemove = new List(); + foreach(var header in newRequest.Headers) { - foreach(var header in redirectOption.SensitiveHeaders) - { - newRequest.Headers.Remove(header); - } + if(redirectOption.ShouldRemoveHeader(header.Key, newRequest.RequestUri, request.RequestUri)) + headersToRemove.Add(header.Key); } + foreach(var headerName in headersToRemove) + newRequest.Headers.Remove(headerName); } // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index fd1d6b77..6d868f5b 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -372,10 +372,13 @@ public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCo [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) { - // Arrange - Configure sensitive headers to be removed on host change + // Arrange - callback removes specific headers when the host changes var redirectOption = new RedirectHandlerOption { - SensitiveHeaders = { "X-Api-Key", "X-Custom-Auth" } + ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => + !newOrigin.Host.Equals(oldOrigin?.Host, StringComparison.OrdinalIgnoreCase) && + (headerName.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) || + headerName.Equals("X-Custom-Auth", StringComparison.OrdinalIgnoreCase)) }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -409,11 +412,13 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStat [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) { - // Arrange - Configure sensitive headers to be removed on scheme change + // Arrange - callback removes specific headers when the scheme changes var redirectOption = new RedirectHandlerOption { AllowRedirectOnSchemeChange = true, - SensitiveHeaders = { "X-Api-Key" } + ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => + !newOrigin.Scheme.Equals(oldOrigin?.Scheme, StringComparison.OrdinalIgnoreCase) && + headerName.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -439,10 +444,12 @@ public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpSt [Fact] public async Task RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() { - // Arrange - Configure sensitive headers + // Arrange - callback only removes the header when the host changes; same-host redirects should leave it intact var redirectOption = new RedirectHandlerOption { - SensitiveHeaders = { "X-Api-Key" } + ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => + !newOrigin.Host.Equals(oldOrigin?.Host, StringComparison.OrdinalIgnoreCase) && + headerName.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -465,6 +472,39 @@ public async Task RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() 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 RedirectWithDifferentHostShouldRemoveSensitiveHeadersCaseInsensitively(HttpStatusCode statusCode) + { + // Arrange - callback matches header name case-insensitively + var redirectOption = new RedirectHandlerOption + { + ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => + headerName.Equals("x-api-key", StringComparison.OrdinalIgnoreCase) // lowercase in callback + }; + 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"); // mixed case on request + + var redirectResponse = new HttpResponseMessage(statusCode); + 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 - header should be removed regardless of casing difference between callback and request + Assert.False(response.RequestMessage?.Headers.Contains("X-Api-Key")); + } + [Theory] [InlineData(HttpStatusCode.MovedPermanently)] // 301 [InlineData(HttpStatusCode.Found)] // 302 From 399e49c206bed80a71220eeef120e8c36137f1e0 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Wed, 25 Feb 2026 11:34:49 -0500 Subject: [PATCH 11/22] linting: use where when possible Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/http/httpClient/Middleware/RedirectHandler.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 9d990f1b..a0390fa4 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -9,6 +9,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using System.Linq; using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; @@ -131,12 +132,10 @@ protected override async Task SendAsync(HttpRequestMessage // Invoke the callback for every header to allow callers to strip additional headers if(redirectOption.ShouldRemoveHeader != null) { - var headersToRemove = new List(); - foreach(var header in newRequest.Headers) - { - if(redirectOption.ShouldRemoveHeader(header.Key, newRequest.RequestUri, request.RequestUri)) - headersToRemove.Add(header.Key); - } + var headersToRemove = newRequest.Headers + .Where(header => redirectOption.ShouldRemoveHeader(header.Key, newRequest.RequestUri, request.RequestUri)) + .Select(header => header.Key) + .ToList(); foreach(var headerName in headersToRemove) newRequest.Headers.Remove(headerName); } From c1ea4bc9368d6e7bc70b26bfec6c9ae1b71cf281 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 25 Feb 2026 13:09:31 -0600 Subject: [PATCH 12/22] changing approach on removing sensitive headers --- .../Options/RedirectHandlerOption.cs | 9 +-- .../httpClient/Middleware/RedirectHandler.cs | 33 ++++----- .../Middleware/RedirectHandlerTests.cs | 67 ++++++++++++++----- 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs index 031dde14..f201fcc1 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; @@ -46,11 +47,11 @@ public int MaxRedirect public bool AllowRedirectOnSchemeChange { get; set; } = false; /// - /// An optional callback invoked for each request header on every redirect to determine whether the header - /// should be removed. Receives the header name, the new origin URI, and the original origin URI (which may - /// be null). Return true to strip the header from the redirected request. + /// 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. + /// Comparisons are case-insensitive. /// - public Func? ShouldRemoveHeader { get; set; } + public ICollection SensitiveHeaders { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); } } diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index a0390fa4..508e1c6d 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -3,13 +3,11 @@ // ------------------------------------------------------------------------------ using System; -using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using System.Linq; using Microsoft.Kiota.Http.HttpClientLibrary.Extensions; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; @@ -122,31 +120,36 @@ protected override async Task SendAsync(HttpRequestMessage } // 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)) + var isDifferentHost = !newRequest.RequestUri.Host.Equals(request.RequestUri?.Host) || + !newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme); + + if(isDifferentHost) { newRequest.Headers.Authorization = null; newRequest.Headers.Remove("Cookie"); - } - // Invoke the callback for every header to allow callers to strip additional headers - if(redirectOption.ShouldRemoveHeader != null) - { - var headersToRemove = newRequest.Headers - .Where(header => redirectOption.ShouldRemoveHeader(header.Key, newRequest.RequestUri, request.RequestUri)) - .Select(header => header.Key) - .ToList(); - foreach(var headerName in headersToRemove) - newRequest.Headers.Remove(headerName); } // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed var proxyResolver = GetProxyResolver(); - if(proxyResolver == null || proxyResolver(newRequest.RequestUri) == null) + var isProxyInactive = proxyResolver == null || proxyResolver(newRequest.RequestUri) == null; + if(isProxyInactive) { newRequest.Headers.ProxyAuthorization = null; } + if(isProxyInactive && isDifferentHost) + { + // Remove any additional sensitive headers configured in the options + if(redirectOption.SensitiveHeaders.Count > 0) + { + foreach(var header in redirectOption.SensitiveHeaders) + { + newRequest.Headers.Remove(header); + } + } + } + // If scheme has changed. Ensure that this has been opted in for security reasons if(!newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme) && !redirectOption.AllowRedirectOnSchemeChange) { diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 6d868f5b..5fc4b4fe 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -372,13 +372,10 @@ public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCo [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) { - // Arrange - callback removes specific headers when the host changes + // Arrange - Configure sensitive headers to be removed on host change var redirectOption = new RedirectHandlerOption { - ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => - !newOrigin.Host.Equals(oldOrigin?.Host, StringComparison.OrdinalIgnoreCase) && - (headerName.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) || - headerName.Equals("X-Custom-Auth", StringComparison.OrdinalIgnoreCase)) + SensitiveHeaders = { "X-Api-Key", "X-Custom-Auth" } }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -412,13 +409,11 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStat [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) { - // Arrange - callback removes specific headers when the scheme changes + // Arrange - Configure sensitive headers to be removed on scheme change var redirectOption = new RedirectHandlerOption { AllowRedirectOnSchemeChange = true, - ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => - !newOrigin.Scheme.Equals(oldOrigin?.Scheme, StringComparison.OrdinalIgnoreCase) && - headerName.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) + SensitiveHeaders = { "X-Api-Key" } }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -444,12 +439,10 @@ public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpSt [Fact] public async Task RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() { - // Arrange - callback only removes the header when the host changes; same-host redirects should leave it intact + // Arrange - Configure sensitive headers var redirectOption = new RedirectHandlerOption { - ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => - !newOrigin.Host.Equals(oldOrigin?.Host, StringComparison.OrdinalIgnoreCase) && - headerName.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) + SensitiveHeaders = { "X-Api-Key" } }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -479,11 +472,10 @@ public async Task RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeadersCaseInsensitively(HttpStatusCode statusCode) { - // Arrange - callback matches header name case-insensitively + // Arrange - header name in options uses different casing than the actual request header var redirectOption = new RedirectHandlerOption { - ShouldRemoveHeader = (headerName, newOrigin, oldOrigin) => - headerName.Equals("x-api-key", StringComparison.OrdinalIgnoreCase) // lowercase in callback + SensitiveHeaders = { "x-api-key" } // lowercase in options }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -501,10 +493,51 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeadersCaseInsen // Act var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); - // Assert - header should be removed regardless of casing difference between callback and request + // Assert - header should be removed regardless of casing difference between option and request Assert.False(response.RequestMessage?.Headers.Contains("X-Api-Key")); } +#if !BROWSER + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectWithActiveProxyAndDifferentHostShouldKeepSensitiveHeaders(HttpStatusCode statusCode) + { + // Arrange - proxy is active for the redirect target, so sensitive headers should be preserved + 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 + { + SensitiveHeaders = { "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"); // different host, but proxy is active + mockHandler.SetHttpResponse(redirectResponse, new HttpResponseMessage(HttpStatusCode.OK)); + + // Act + var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); + + // Assert - sensitive header should be kept because an active proxy is routing the request + Assert.NotSame(response.RequestMessage, httpRequestMessage); + Assert.True(response.RequestMessage?.Headers.Contains("X-Api-Key")); + } +#endif + [Theory] [InlineData(HttpStatusCode.MovedPermanently)] // 301 [InlineData(HttpStatusCode.Found)] // 302 From 588b9c6a1deea729ba8deab002334bb6a9c036ff Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 25 Feb 2026 13:44:58 -0600 Subject: [PATCH 13/22] Fixing format and test coverage --- .../httpClient/Middleware/RedirectHandler.cs | 2 - .../Middleware/RedirectHandlerTests.cs | 63 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 508e1c6d..221f3f48 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -122,12 +122,10 @@ protected override async Task SendAsync(HttpRequestMessage // Remove Authorization and Cookie header if http request's scheme or host changes var isDifferentHost = !newRequest.RequestUri.Host.Equals(request.RequestUri?.Host) || !newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme); - if(isDifferentHost) { newRequest.Headers.Authorization = null; newRequest.Headers.Remove("Cookie"); - } // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index 5fc4b4fe..f9d838d2 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -725,5 +725,68 @@ public async Task RedirectWithNoProxyConfiguredShouldRemoveProxyAuthHeader() Assert.Null(response.RequestMessage?.Headers.ProxyAuthorization); } #endif + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task RedirectResponseWithoutLocationHeaderShouldThrow(HttpStatusCode statusCode) + { + 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 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); + } + + [Theory] + [InlineData(HttpStatusCode.MovedPermanently)] // 301 + [InlineData(HttpStatusCode.Found)] // 302 + [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 + [InlineData((HttpStatusCode)308)] // 308 + public async Task ShouldNotRedirectWhenMaxRedirectIsZero(HttpStatusCode statusCode) + { + 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 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); + } } } From afaadca0dced9be112c4b73560a17a16af9d8923 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 25 Feb 2026 14:09:12 -0600 Subject: [PATCH 14/22] sonar code suggestions --- src/http/httpClient/Middleware/RedirectHandler.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 221f3f48..54c9148b 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -136,16 +136,14 @@ protected override async Task SendAsync(HttpRequestMessage newRequest.Headers.ProxyAuthorization = null; } - if(isProxyInactive && isDifferentHost) + if(isProxyInactive && isDifferentHost && redirectOption.SensitiveHeaders.Count > 0) { // Remove any additional sensitive headers configured in the options - if(redirectOption.SensitiveHeaders.Count > 0) + foreach(var header in redirectOption.SensitiveHeaders) { - foreach(var header in redirectOption.SensitiveHeaders) - { - newRequest.Headers.Remove(header); - } + newRequest.Headers.Remove(header); } + } // If scheme has changed. Ensure that this has been opted in for security reasons From 9677a7e94e00180209faca0792f4ea7ff144c3fc Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:24:22 -0800 Subject: [PATCH 15/22] fix(security): add customizable callback to allow removing headers on redirect --- .../Options/RedirectHandlerOption.cs | 43 ++++++++++++++++--- .../httpClient/Middleware/RedirectHandler.cs | 27 +----------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs index f201fcc1..d85e6be9 100644 --- a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs +++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs @@ -3,7 +3,6 @@ // ------------------------------------------------------------------------------ using System; -using System.Collections.Generic; using System.Net.Http; using Microsoft.Kiota.Abstractions; @@ -47,11 +46,43 @@ public int MaxRedirect 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. - /// Comparisons are case-insensitive. + /// 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 ICollection SensitiveHeaders { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); + 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 54c9148b..f8f92cf9 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -119,32 +119,9 @@ protected override async Task SendAsync(HttpRequestMessage newRequest.RequestUri = new Uri(baseAddress + response.Headers.Location); } - // Remove Authorization and Cookie header if http request's scheme or host changes - var isDifferentHost = !newRequest.RequestUri.Host.Equals(request.RequestUri?.Host) || - !newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme); - if(isDifferentHost) - { - newRequest.Headers.Authorization = null; - newRequest.Headers.Remove("Cookie"); - } - - // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed + // Scrub sensitive headers before following the redirect var proxyResolver = GetProxyResolver(); - var isProxyInactive = proxyResolver == null || proxyResolver(newRequest.RequestUri) == null; - if(isProxyInactive) - { - newRequest.Headers.ProxyAuthorization = null; - } - - if(isProxyInactive && isDifferentHost && redirectOption.SensitiveHeaders.Count > 0) - { - // Remove any additional sensitive headers configured in the options - foreach(var header in redirectOption.SensitiveHeaders) - { - newRequest.Headers.Remove(header); - } - - } + 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) From 746a846767042f5dd34492498222d261b4e1875e Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:26:13 -0800 Subject: [PATCH 16/22] fix(security): add customizable callback to remove headers on redirect --- .../Options/RedirectHandlerOption.cs | 12 +++---- .../httpClient/Middleware/RedirectHandler.cs | 32 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs index d85e6be9..47379200 100644 --- a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs +++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs @@ -28,7 +28,7 @@ public int MaxRedirect } set { - if(value > MaxMaxRedirect) + if (value > MaxMaxRedirect) throw new InvalidOperationException($"Maximum value for {nameof(MaxRedirect)} property exceeded "); _maxRedirect = value; @@ -64,14 +64,14 @@ public int MaxRedirect /// 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)); + 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) + if (isDifferentHostOrScheme) { request.Headers.Authorization = null; request.Headers.Remove("Cookie"); @@ -79,7 +79,7 @@ public static void DefaultScrubSensitiveHeaders(HttpRequestMessage request, Uri // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed var isProxyInactive = proxyResolver == null || proxyResolver(newUri) == null; - if(isProxyInactive) + if (isProxyInactive) { request.Headers.ProxyAuthorization = null; } diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index f8f92cf9..2ef4329c 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -43,13 +43,13 @@ internal RedirectHandlerOption RedirectOption /// The . protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if(request == null) throw new ArgumentNullException(nameof(request)); + if (request == null) throw new ArgumentNullException(nameof(request)); var redirectOption = request.GetRequestOption() ?? RedirectOption; ActivitySource? activitySource; Activity? activity; - if(request.GetRequestOption() is { } obsOptions) + if (request.GetRequestOption() is { } obsOptions) { activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); activity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)}"); @@ -67,9 +67,9 @@ protected override async Task SendAsync(HttpRequestMessage var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); // check response status code and redirect handler option - if(ShouldRedirect(response, redirectOption)) + if (ShouldRedirect(response, redirectOption)) { - if(response.Headers.Location == null) + if (response.Headers.Location == null) { throw new InvalidOperationException( "Unable to perform redirect as Location Header is not set in response", @@ -78,13 +78,13 @@ protected override async Task SendAsync(HttpRequestMessage var redirectCount = 0; - while(redirectCount < redirectOption.MaxRedirect) + while (redirectCount < redirectOption.MaxRedirect) { using var redirectActivity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)} - redirect {redirectCount}"); redirectActivity?.SetTag("com.microsoft.kiota.handler.redirect.count", redirectCount); redirectActivity?.SetTag("http.response.status_code", response.StatusCode); // Drain response content to free responses. - if(response.Content != null) + if (response.Content != null) { #if NET5_0_OR_GREATER await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); @@ -95,21 +95,21 @@ protected override async Task SendAsync(HttpRequestMessage // general clone request with internal CloneAsync (see CloneAsync for details) extension method var originalRequest = response.RequestMessage; - if(originalRequest == null) + if (originalRequest == null) { return response;// We can't clone the original request to replay it. } var newRequest = await originalRequest.CloneAsync(cancellationToken).ConfigureAwait(false); // status code == 303: change request method from post to get and content to be null - if(response.StatusCode == HttpStatusCode.SeeOther) + if (response.StatusCode == HttpStatusCode.SeeOther) { newRequest.Content = null; newRequest.Method = HttpMethod.Get; } // Set newRequestUri from response - if(response.Headers.Location?.IsAbsoluteUri ?? false) + if (response.Headers.Location?.IsAbsoluteUri ?? false) { newRequest.RequestUri = response.Headers.Location; } @@ -124,7 +124,7 @@ protected override async Task SendAsync(HttpRequestMessage 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) + if (!newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme) && !redirectOption.AllowRedirectOnSchemeChange) { throw new InvalidOperationException( $"Redirects with changing schemes not allowed by default. You can change this by modifying the {nameof(redirectOption.AllowRedirectOnSchemeChange)} option", @@ -135,7 +135,7 @@ protected override async Task SendAsync(HttpRequestMessage response = await base.SendAsync(newRequest, cancellationToken).ConfigureAwait(false); // Check response status code - if(ShouldRedirect(response, redirectOption)) + if (ShouldRedirect(response, redirectOption)) { redirectCount++; } @@ -187,7 +187,7 @@ private static bool IsRedirect(HttpStatusCode statusCode) private Func? GetProxyResolver() { var proxy = GetProxyFromFinalHandler(); - if(proxy == null) + if (proxy == null) return null; return destination => proxy.IsBypassed(destination) ? null : proxy.GetProxy(destination); } @@ -203,19 +203,19 @@ private static bool IsRedirect(HttpStatusCode statusCode) return null; #else var handler = InnerHandler; - while(handler != null) + while (handler != null) { #if NETFRAMEWORK if(handler is WinHttpHandler winHttpHandler) return winHttpHandler.Proxy; #endif #if NET5_0_OR_GREATER - if(handler is SocketsHttpHandler socketsHandler) + if (handler is SocketsHttpHandler socketsHandler) return socketsHandler.Proxy; #endif - if(handler is HttpClientHandler httpClientHandler) + if (handler is HttpClientHandler httpClientHandler) return httpClientHandler.Proxy; - if(handler is DelegatingHandler delegatingHandler) + if (handler is DelegatingHandler delegatingHandler) handler = delegatingHandler.InnerHandler; else break; From 1f7b2cbb9e6ae7da6930b9a629b744d8410f84b7 Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:27:51 -0800 Subject: [PATCH 17/22] test: validate removing headers on redirect --- .../Middleware/RedirectHandlerTests.cs | 131 ++++++++++++------ 1 file changed, 87 insertions(+), 44 deletions(-) diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index f9d838d2..f4e71ac1 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -47,7 +47,7 @@ internal class MockDelegatingRedirectHandler : DelegatingHandler protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if(!_response1Sent) + if (!_response1Sent) { _response1Sent = true; _response1!.RequestMessage = request; @@ -128,7 +128,7 @@ public void RedirectHandler_RedirectOptionConstructor() [Fact] public async Task OkStatusShouldPassThrough() { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { // Arrange var redirectResponse = new HttpResponseMessage(HttpStatusCode.OK); @@ -148,7 +148,7 @@ public async Task OkStatusShouldPassThrough() [InlineData((HttpStatusCode)308)] // 308 not available in netstandard public async Task ShouldRedirectSameMethodAndContent(HttpStatusCode statusCode) { - using(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"); @@ -174,7 +174,7 @@ public async Task ShouldRedirectSameMethodAndContent(HttpStatusCode statusCode) public async Task ShouldRedirectChangeMethodAndContent() { - using(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"); @@ -199,7 +199,7 @@ public async Task ShouldRedirectChangeMethodAndContent() [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -223,7 +223,7 @@ public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAllowRedirectOnSchemeChangeIsDisabled(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -246,7 +246,7 @@ public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAl [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -266,7 +266,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedire [Fact] public async Task RedirectWithSameHostShouldKeepAuthHeader() { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -285,7 +285,7 @@ public async Task RedirectWithSameHostShouldKeepAuthHeader() [Fact] public async Task RedirectWithRelativeUrlShouldKeepRequestHost() { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + 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); @@ -301,7 +301,7 @@ public async Task RedirectWithRelativeUrlShouldKeepRequestHost() [Fact] public async Task ExceedMaxRedirectsShouldThrowsException() { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { // Arrange var response1 = new HttpResponseMessage(HttpStatusCode.Redirect); @@ -326,7 +326,7 @@ public async Task ExceedMaxRedirectsShouldThrowsException() [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + 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"); @@ -349,7 +349,7 @@ public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeaderWhenNoProx [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Add("Cookie", "session=abc123"); @@ -370,12 +370,25 @@ public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCo [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) + public async Task RedirectWithDifferentHostShouldRemoveCustomHeadersWithCustomCallback(HttpStatusCode statusCode) { - // Arrange - Configure sensitive headers to be removed on host change + // Arrange - Custom callback that removes additional headers on host change var redirectOption = new RedirectHandlerOption { - SensitiveHeaders = { "X-Api-Key", "X-Custom-Auth" } + 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) @@ -395,7 +408,7 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStat // Act var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); - // Assert - Sensitive headers should be removed, but non-sensitive should remain + // Assert - Custom 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")); @@ -407,13 +420,23 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeaders(HttpStat [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpStatusCode statusCode) + public async Task RedirectWithDifferentSchemeShouldRemoveCustomHeadersWithCustomCallback(HttpStatusCode statusCode) { - // Arrange - Configure sensitive headers to be removed on scheme change + // Arrange - Custom callback that removes additional headers on scheme change var redirectOption = new RedirectHandlerOption { AllowRedirectOnSchemeChange = true, - SensitiveHeaders = { "X-Api-Key" } + 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) @@ -431,18 +454,28 @@ public async Task RedirectWithDifferentSchemeShouldRemoveSensitiveHeaders(HttpSt // Act var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); - // Assert - Sensitive headers should be removed on scheme change + // 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 RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() + public async Task RedirectWithSameHostAndSchemeCustomCallbackShouldKeepCustomHeaders() { - // Arrange - Configure sensitive headers + // Arrange - Custom callback that only removes headers on host/scheme change var redirectOption = new RedirectHandlerOption { - SensitiveHeaders = { "X-Api-Key" } + 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) @@ -460,22 +493,19 @@ public async Task RedirectWithSameHostAndSchemeShouldKeepSensitiveHeaders() // Act var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); - // Assert - Sensitive headers should be kept when host and scheme are the same + // Assert - Custom headers should be kept when host and scheme are the same Assert.NotSame(response.RequestMessage, httpRequestMessage); 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 RedirectWithDifferentHostShouldRemoveSensitiveHeadersCaseInsensitively(HttpStatusCode statusCode) + [Fact] + public async Task DefaultScrubSensitiveHeadersMethodCanBeReferencedDirectly() { - // Arrange - header name in options uses different casing than the actual request header + // Arrange - Verify that the default static method can be referenced and used var redirectOption = new RedirectHandlerOption { - SensitiveHeaders = { "x-api-key" } // lowercase in options + // Explicitly set to default to verify it's accessible + ScrubSensitiveHeaders = RedirectHandlerOption.DefaultScrubSensitiveHeaders }; var mockHandler = new MockRedirectHandler(); var redirectHandler = new RedirectHandler(redirectOption) @@ -484,17 +514,17 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeadersCaseInsen }; 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"); // mixed case on request + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "token"); - var redirectResponse = new HttpResponseMessage(statusCode); + 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 - header should be removed regardless of casing difference between option and request - Assert.False(response.RequestMessage?.Headers.Contains("X-Api-Key")); + // Assert - Default behavior should remove Authorization on host change + Assert.Null(response.RequestMessage?.Headers.Authorization); } #if !BROWSER @@ -503,9 +533,9 @@ public async Task RedirectWithDifferentHostShouldRemoveSensitiveHeadersCaseInsen [InlineData(HttpStatusCode.Found)] // 302 [InlineData(HttpStatusCode.TemporaryRedirect)] // 307 [InlineData((HttpStatusCode)308)] // 308 - public async Task RedirectWithActiveProxyAndDifferentHostShouldKeepSensitiveHeaders(HttpStatusCode statusCode) + public async Task CustomCallbackCanUseProxyResolverToKeepHeadersWhenProxyActive(HttpStatusCode statusCode) { - // Arrange - proxy is active for the redirect target, so sensitive headers should be preserved + // 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 @@ -514,7 +544,20 @@ public async Task RedirectWithActiveProxyAndDifferentHostShouldKeepSensitiveHead }; var redirectOption = new RedirectHandlerOption { - SensitiveHeaders = { "X-Api-Key" } + 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) { @@ -532,7 +575,7 @@ public async Task RedirectWithActiveProxyAndDifferentHostShouldKeepSensitiveHead // Act var response = await invoker.SendAsync(httpRequestMessage, CancellationToken.None); - // Assert - sensitive header should be kept because an active proxy is routing the request + // Assert - X-Api-Key should be kept because proxy is active Assert.NotSame(response.RequestMessage, httpRequestMessage); Assert.True(response.RequestMessage?.Headers.Contains("X-Api-Key")); } @@ -545,7 +588,7 @@ public async Task RedirectWithActiveProxyAndDifferentHostShouldKeepSensitiveHead [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + 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"); @@ -569,7 +612,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderWhenNoPr [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { // Arrange httpRequestMessage.Headers.Add("Cookie", "session=abc123"); @@ -589,7 +632,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedi [Fact] public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured() { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + 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"); @@ -608,7 +651,7 @@ public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConf [Fact] public async Task RedirectWithSameHostShouldKeepCookieHeader() { - using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Add("Cookie", "session=abc123"); From cb728eda755223d68dc7123bf9b3d2201ba7b8ec Mon Sep 17 00:00:00 2001 From: "Michael Mainer (from Dev Box)" <8527305+MIchaelMainer@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:43:09 -0800 Subject: [PATCH 18/22] chore: fix whitespace --- .../Options/RedirectHandlerOption.cs | 12 +++--- .../httpClient/Middleware/RedirectHandler.cs | 30 +++++++------- .../Middleware/RedirectHandlerTests.cs | 40 +++++++++---------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs index 47379200..d85e6be9 100644 --- a/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs +++ b/src/http/httpClient/Middleware/Options/RedirectHandlerOption.cs @@ -28,7 +28,7 @@ public int MaxRedirect } set { - if (value > MaxMaxRedirect) + if(value > MaxMaxRedirect) throw new InvalidOperationException($"Maximum value for {nameof(MaxRedirect)} property exceeded "); _maxRedirect = value; @@ -64,14 +64,14 @@ public int MaxRedirect /// 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)); + 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) + if(isDifferentHostOrScheme) { request.Headers.Authorization = null; request.Headers.Remove("Cookie"); @@ -79,7 +79,7 @@ public static void DefaultScrubSensitiveHeaders(HttpRequestMessage request, Uri // Remove ProxyAuthorization if no proxy is configured or the URL is bypassed var isProxyInactive = proxyResolver == null || proxyResolver(newUri) == null; - if (isProxyInactive) + if(isProxyInactive) { request.Headers.ProxyAuthorization = null; } diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 2ef4329c..b6bf83ed 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -43,13 +43,13 @@ internal RedirectHandlerOption RedirectOption /// The . protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (request == null) throw new ArgumentNullException(nameof(request)); + if(request == null) throw new ArgumentNullException(nameof(request)); var redirectOption = request.GetRequestOption() ?? RedirectOption; ActivitySource? activitySource; Activity? activity; - if (request.GetRequestOption() is { } obsOptions) + if(request.GetRequestOption() is { } obsOptions) { activitySource = ActivitySourceRegistry.DefaultInstance.GetOrCreateActivitySource(obsOptions.TracerInstrumentationName); activity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)}"); @@ -67,9 +67,9 @@ protected override async Task SendAsync(HttpRequestMessage var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); // check response status code and redirect handler option - if (ShouldRedirect(response, redirectOption)) + if(ShouldRedirect(response, redirectOption)) { - if (response.Headers.Location == null) + if(response.Headers.Location == null) { throw new InvalidOperationException( "Unable to perform redirect as Location Header is not set in response", @@ -78,13 +78,13 @@ protected override async Task SendAsync(HttpRequestMessage var redirectCount = 0; - while (redirectCount < redirectOption.MaxRedirect) + while(redirectCount < redirectOption.MaxRedirect) { using var redirectActivity = activitySource?.StartActivity($"{nameof(RedirectHandler)}_{nameof(SendAsync)} - redirect {redirectCount}"); redirectActivity?.SetTag("com.microsoft.kiota.handler.redirect.count", redirectCount); redirectActivity?.SetTag("http.response.status_code", response.StatusCode); // Drain response content to free responses. - if (response.Content != null) + if(response.Content != null) { #if NET5_0_OR_GREATER await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); @@ -95,21 +95,21 @@ protected override async Task SendAsync(HttpRequestMessage // general clone request with internal CloneAsync (see CloneAsync for details) extension method var originalRequest = response.RequestMessage; - if (originalRequest == null) + if(originalRequest == null) { return response;// We can't clone the original request to replay it. } var newRequest = await originalRequest.CloneAsync(cancellationToken).ConfigureAwait(false); // status code == 303: change request method from post to get and content to be null - if (response.StatusCode == HttpStatusCode.SeeOther) + if(response.StatusCode == HttpStatusCode.SeeOther) { newRequest.Content = null; newRequest.Method = HttpMethod.Get; } // Set newRequestUri from response - if (response.Headers.Location?.IsAbsoluteUri ?? false) + if(response.Headers.Location?.IsAbsoluteUri ?? false) { newRequest.RequestUri = response.Headers.Location; } @@ -124,7 +124,7 @@ protected override async Task SendAsync(HttpRequestMessage 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) + if(!newRequest.RequestUri.Scheme.Equals(request.RequestUri?.Scheme) && !redirectOption.AllowRedirectOnSchemeChange) { throw new InvalidOperationException( $"Redirects with changing schemes not allowed by default. You can change this by modifying the {nameof(redirectOption.AllowRedirectOnSchemeChange)} option", @@ -135,7 +135,7 @@ protected override async Task SendAsync(HttpRequestMessage response = await base.SendAsync(newRequest, cancellationToken).ConfigureAwait(false); // Check response status code - if (ShouldRedirect(response, redirectOption)) + if(ShouldRedirect(response, redirectOption)) { redirectCount++; } @@ -187,7 +187,7 @@ private static bool IsRedirect(HttpStatusCode statusCode) private Func? GetProxyResolver() { var proxy = GetProxyFromFinalHandler(); - if (proxy == null) + if(proxy == null) return null; return destination => proxy.IsBypassed(destination) ? null : proxy.GetProxy(destination); } @@ -203,7 +203,7 @@ private static bool IsRedirect(HttpStatusCode statusCode) return null; #else var handler = InnerHandler; - while (handler != null) + while(handler != null) { #if NETFRAMEWORK if(handler is WinHttpHandler winHttpHandler) @@ -213,9 +213,9 @@ private static bool IsRedirect(HttpStatusCode statusCode) if (handler is SocketsHttpHandler socketsHandler) return socketsHandler.Proxy; #endif - if (handler is HttpClientHandler httpClientHandler) + if(handler is HttpClientHandler httpClientHandler) return httpClientHandler.Proxy; - if (handler is DelegatingHandler delegatingHandler) + if(handler is DelegatingHandler delegatingHandler) handler = delegatingHandler.InnerHandler; else break; diff --git a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs index f4e71ac1..3a73bd03 100644 --- a/tests/http/httpClient/Middleware/RedirectHandlerTests.cs +++ b/tests/http/httpClient/Middleware/RedirectHandlerTests.cs @@ -47,7 +47,7 @@ internal class MockDelegatingRedirectHandler : DelegatingHandler protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - if (!_response1Sent) + if(!_response1Sent) { _response1Sent = true; _response1!.RequestMessage = request; @@ -128,7 +128,7 @@ public void RedirectHandler_RedirectOptionConstructor() [Fact] public async Task OkStatusShouldPassThrough() { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { // Arrange var redirectResponse = new HttpResponseMessage(HttpStatusCode.OK); @@ -148,7 +148,7 @@ public async Task OkStatusShouldPassThrough() [InlineData((HttpStatusCode)308)] // 308 not available in netstandard public async Task ShouldRedirectSameMethodAndContent(HttpStatusCode statusCode) { - using (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"); @@ -174,7 +174,7 @@ public async Task ShouldRedirectSameMethodAndContent(HttpStatusCode statusCode) public async Task ShouldRedirectChangeMethodAndContent() { - using (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"); @@ -199,7 +199,7 @@ public async Task ShouldRedirectChangeMethodAndContent() [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -223,7 +223,7 @@ public async Task RedirectWithDifferentHostShouldRemoveAuthHeader(HttpStatusCode public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAllowRedirectOnSchemeChangeIsDisabled(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -246,7 +246,7 @@ public async Task RedirectWithDifferentSchemeThrowsInvalidOperationExceptionIfAl [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -266,7 +266,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveAuthHeaderIfAllowRedire [Fact] public async Task RedirectWithSameHostShouldKeepAuthHeader() { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("fooAuth", "aparam"); @@ -285,7 +285,7 @@ public async Task RedirectWithSameHostShouldKeepAuthHeader() [Fact] public async Task RedirectWithRelativeUrlShouldKeepRequestHost() { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + 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); @@ -301,7 +301,7 @@ public async Task RedirectWithRelativeUrlShouldKeepRequestHost() [Fact] public async Task ExceedMaxRedirectsShouldThrowsException() { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { // Arrange var response1 = new HttpResponseMessage(HttpStatusCode.Redirect); @@ -326,7 +326,7 @@ public async Task ExceedMaxRedirectsShouldThrowsException() [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + 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"); @@ -349,7 +349,7 @@ public async Task RedirectWithDifferentHostShouldRemoveProxyAuthHeaderWhenNoProx [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentHostShouldRemoveCookieHeader(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Add("Cookie", "session=abc123"); @@ -383,7 +383,7 @@ public async Task RedirectWithDifferentHostShouldRemoveCustomHeadersWithCustomCa // 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) + if(isDifferentHostOrScheme) { request.Headers.Remove("X-Api-Key"); request.Headers.Remove("X-Custom-Auth"); @@ -432,7 +432,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveCustomHeadersWithCustom var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); - if (isDifferentHostOrScheme) + if(isDifferentHostOrScheme) { request.Headers.Remove("X-Api-Key"); } @@ -471,7 +471,7 @@ public async Task RedirectWithSameHostAndSchemeCustomCallbackShouldKeepCustomHea var isDifferentHostOrScheme = !newUri.Host.Equals(originalUri.Host, StringComparison.OrdinalIgnoreCase) || !newUri.Scheme.Equals(originalUri.Scheme, StringComparison.OrdinalIgnoreCase); - if (isDifferentHostOrScheme) + if(isDifferentHostOrScheme) { request.Headers.Remove("X-Api-Key"); } @@ -553,7 +553,7 @@ public async Task CustomCallbackCanUseProxyResolverToKeepHeadersWhenProxyActive( 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) + if(isProxyInactive && isDifferentHostOrScheme) { request.Headers.Remove("X-Api-Key"); } @@ -588,7 +588,7 @@ public async Task CustomCallbackCanUseProxyResolverToKeepHeadersWhenProxyActive( [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderWhenNoProxyConfigured(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + 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"); @@ -612,7 +612,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveProxyAuthHeaderWhenNoPr [InlineData((HttpStatusCode)308)] // 308 public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedirectOnSchemeChangeIsEnabled(HttpStatusCode statusCode) { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.org/foo")) { // Arrange httpRequestMessage.Headers.Add("Cookie", "session=abc123"); @@ -632,7 +632,7 @@ public async Task RedirectWithDifferentSchemeShouldRemoveCookieHeaderIfAllowRedi [Fact] public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConfigured() { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + 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"); @@ -651,7 +651,7 @@ public async Task RedirectWithSameHostShouldRemoveProxyAuthHeaderWhenNoProxyConf [Fact] public async Task RedirectWithSameHostShouldKeepCookieHeader() { - using (var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) + using(var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, "http://example.org/foo")) { // Arrange httpRequestMessage.Headers.Add("Cookie", "session=abc123"); From c16b97f5ab098679a070c07b4b30ee5bc50ccf08 Mon Sep 17 00:00:00 2001 From: Michael Mainer <8527305+MIchaelMainer@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:01:51 -0800 Subject: [PATCH 19/22] chore: make web proxy static Co-authored-by: Vincent Biret --- src/http/httpClient/Middleware/RedirectHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index b6bf83ed..3b9202ff 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -196,7 +196,7 @@ private static bool IsRedirect(HttpStatusCode statusCode) /// 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() + private static IWebProxy? GetProxyFromFinalHandler() { #if BROWSER // Browser platform does not support proxy configuration From 21755df31e88db2b96e7b389c4500004b232e25f Mon Sep 17 00:00:00 2001 From: Michael Mainer <8527305+MIchaelMainer@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:02:22 -0800 Subject: [PATCH 20/22] chore: make proxy resolver static Co-authored-by: Vincent Biret --- src/http/httpClient/Middleware/RedirectHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 3b9202ff..82c2ed3d 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -184,7 +184,7 @@ 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() + private static Func? GetProxyResolver() { var proxy = GetProxyFromFinalHandler(); if(proxy == null) From b70eb923274209e4ab98a83474304ffcd378c31e Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 27 Feb 2026 11:40:40 -0600 Subject: [PATCH 21/22] Update src/http/httpClient/Middleware/RedirectHandler.cs Co-authored-by: Vincent Biret --- src/http/httpClient/Middleware/RedirectHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index 82c2ed3d..b6a2d7cd 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -196,7 +196,7 @@ private static bool IsRedirect(HttpStatusCode statusCode) /// 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 static IWebProxy? GetProxyFromFinalHandler() + private IWebProxy? GetProxyFromFinalHandler() { #if BROWSER // Browser platform does not support proxy configuration From c845bdd8c2980eecd115103c1b4726032dcba889 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 27 Feb 2026 12:32:12 -0600 Subject: [PATCH 22/22] reverting back static change --- src/http/httpClient/Middleware/RedirectHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http/httpClient/Middleware/RedirectHandler.cs b/src/http/httpClient/Middleware/RedirectHandler.cs index b6a2d7cd..b6bf83ed 100644 --- a/src/http/httpClient/Middleware/RedirectHandler.cs +++ b/src/http/httpClient/Middleware/RedirectHandler.cs @@ -184,7 +184,7 @@ 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 static Func? GetProxyResolver() + private Func? GetProxyResolver() { var proxy = GetProxyFromFinalHandler(); if(proxy == null)