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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 {