From 71f9ab64a35710051ae954ca334599626ebb45a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 11:44:43 +0000 Subject: [PATCH 1/5] Enable proactive proxy authentication for Basic auth Many proxies require the Proxy-Authorization header on the first request rather than sending a 407 Proxy Authentication Required response. This is especially common for: 1. Proxies that drop/ignore unauthenticated connections 2. HTTPS CONNECT tunnels where connection reuse isn't possible 3. Environment variable proxies (HTTP_PROXY/HTTPS_PROXY) with embedded credentials (http://user:password@proxy:port) This change modifies SendWithProxyAuthAsync to proactively send Basic authentication when credentials are available from the ICredentials provider, rather than waiting for a 407 challenge that may never come. The reactive authentication flow is still preserved for cases where the proxy does send a 407 response with different auth scheme requirements (Digest, NTLM, Negotiate). --- .../Http/SocketsHttpHandler/AuthenticationHelper.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs index 6a7e612a5ba012..5fe818ab763dbb 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs @@ -359,6 +359,18 @@ await TrySetDigestAuthToken(request, challenge.Credential, digestResponse, isPro public static ValueTask SendWithProxyAuthAsync(HttpRequestMessage request, Uri proxyUri, bool async, ICredentials proxyCredentials, bool doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken) { + // For proxy authentication, try to send Basic auth proactively when credentials are available. + // Many proxies require the Proxy-Authorization header on the first request, especially for + // HTTPS CONNECT tunnels where the proxy may close the connection after sending 407. + // This is particularly important for environment variable proxies (HTTP_PROXY/HTTPS_PROXY) + // that include credentials in the URL (e.g., http://user:password@proxy:port). + NetworkCredential? credential = proxyCredentials.GetCredential(proxyUri, BasicScheme); + if (credential != null && credential != CredentialCache.DefaultNetworkCredentials) + { + // Proactively set Basic auth header before sending the request + SetBasicAuthToken(request, credential, isProxyAuth: true); + } + return SendWithAuthAsync(request, proxyUri, async, proxyCredentials, preAuthenticate: false, isProxyAuth: true, doRequestAuth, pool, cancellationToken); } From 49a7b92908f5928c47dce93c30e3a84a8d924767 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 17:06:49 +0000 Subject: [PATCH 2/5] Add tests for proactive proxy authentication Add functional tests to verify that Basic proxy authentication is sent proactively when credentials are provided, without waiting for a 407 challenge. This tests the fix for proxies that don't send 407 responses. Tests cover: - Proactive auth on first HTTP request - Proactive auth for HTTPS CONNECT tunnels - DefaultNetworkCredentials are NOT sent proactively (NTLM/Negotiate only) --- ...ttpClientHandlerTest.ProactiveProxyAuth.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs new file mode 100644 index 00000000000000..eaa467ef16f3d3 --- /dev/null +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Test.Common; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace System.Net.Http.Functional.Tests +{ + public abstract class ProactiveProxyAuthTest : HttpClientHandlerTestBase + { + public ProactiveProxyAuthTest(ITestOutputHelper helper) : base(helper) { } + + /// + /// Tests that when proxy credentials are provided, the Proxy-Authorization header + /// is sent proactively on the first request without waiting for a 407 challenge. + /// This is important for proxies that don't send 407 responses but instead + /// drop or reject unauthenticated connections. + /// + [Fact] + public async Task ProxyAuth_CredentialsProvided_SentProactivelyOnFirstRequest() + { + const string username = "testuser"; + const string password = "testpassword"; + + // Create a proxy server that does NOT require authentication (AuthenticationSchemes.None) + // This allows us to verify that credentials are sent proactively even without a 407 challenge + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using HttpClientHandler handler = CreateHttpClientHandler(); + handler.Proxy = new WebProxy(proxyServer.Uri) + { + Credentials = new NetworkCredential(username, password) + }; + + using HttpClient client = CreateHttpClient(handler); + + // Make a request - credentials should be sent proactively + HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // Verify that the proxy received the Proxy-Authorization header on the first request + Assert.Single(proxyServer.Requests); + var proxyRequest = proxyServer.Requests[0]; + Assert.Equal("Basic", proxyRequest.AuthorizationHeaderValueScheme); + Assert.NotNull(proxyRequest.AuthorizationHeaderValueToken); + + // Verify the credentials are correct (Base64 encoded "testuser:testpassword") + string expectedToken = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes($"{username}:{password}")); + Assert.Equal(expectedToken, proxyRequest.AuthorizationHeaderValueToken); + }, + async server => + { + await server.HandleRequestAsync(content: "Success"); + }); + } + + /// + /// Tests that proactive proxy auth works for HTTPS CONNECT tunnels. + /// + [Fact] + public async Task ProxyAuth_HttpsConnect_CredentialsSentProactively() + { + const string username = "tunneluser"; + const string password = "tunnelpass"; + + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using HttpClientHandler handler = CreateHttpClientHandler(allowAllCertificates: true); + handler.Proxy = new WebProxy(proxyServer.Uri) + { + Credentials = new NetworkCredential(username, password) + }; + + using HttpClient client = CreateHttpClient(handler); + + HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // For HTTPS, the proxy receives a CONNECT request + Assert.True(proxyServer.Requests.Count >= 1); + var connectRequest = proxyServer.Requests[0]; + + // Verify CONNECT request has proactive auth + Assert.Equal("Basic", connectRequest.AuthorizationHeaderValueScheme); + Assert.NotNull(connectRequest.AuthorizationHeaderValueToken); + }, + async server => + { + await server.HandleRequestAsync(content: "Secure Success"); + }, + options: new GenericLoopbackOptions { UseSsl = true }); + } + + /// + /// Tests that DefaultNetworkCredentials are NOT sent proactively + /// (they are only for NTLM/Negotiate which require challenge-response). + /// + [Fact] + public async Task ProxyAuth_DefaultCredentials_NotSentProactively() + { + using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using HttpClientHandler handler = CreateHttpClientHandler(); + handler.Proxy = new WebProxy(proxyServer.Uri) + { + Credentials = CredentialCache.DefaultNetworkCredentials + }; + + using HttpClient client = CreateHttpClient(handler); + + HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // DefaultNetworkCredentials should NOT result in proactive Basic auth + Assert.Single(proxyServer.Requests); + var proxyRequest = proxyServer.Requests[0]; + Assert.Null(proxyRequest.AuthorizationHeaderValueScheme); + }, + async server => + { + await server.HandleRequestAsync(content: "Success"); + }); + } + } + + public sealed class ProactiveProxyAuthTest_Http11 : ProactiveProxyAuthTest + { + public ProactiveProxyAuthTest_Http11(ITestOutputHelper helper) : base(helper) { } + protected override Version UseVersion => HttpVersion.Version11; + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.SupportsAlpn))] + public sealed class ProactiveProxyAuthTest_Http2 : ProactiveProxyAuthTest + { + public ProactiveProxyAuthTest_Http2(ITestOutputHelper helper) : base(helper) { } + protected override Version UseVersion => HttpVersion.Version20; + } +} From 54b532683f1d2a31e39672f77d40b4ea268c15e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 22:03:57 +0000 Subject: [PATCH 3/5] Make proactive proxy auth opt-in via AppContext switch Address PR feedback by making proactive proxy authentication opt-in rather than enabled by default. This preserves RFC 7235 compliant behavior (waiting for 407 challenge) as the default. Users can enable proactive auth via: - AppContext switch: System.Net.Http.EnableProactiveProxyAuth - Environment variable: DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH=1 This is useful for proxies that don't send 407 challenges but instead drop or reject unauthenticated connections (especially HTTPS CONNECT tunnel proxies). Updated tests to use RemoteExecutor with the environment variable to verify both opt-in and default behavior. --- .../AuthenticationHelper.cs | 50 +++- ...ttpClientHandlerTest.ProactiveProxyAuth.cs | 220 ++++++++++-------- 2 files changed, 169 insertions(+), 101 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs index 5fe818ab763dbb..373297e12d3b7c 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs @@ -17,6 +17,38 @@ internal static partial class AuthenticationHelper private const string NtlmScheme = "NTLM"; private const string NegotiateScheme = "Negotiate"; + private const string EnableProactiveProxyAuthCtxSwitch = "System.Net.Http.EnableProactiveProxyAuth"; + private const string EnableProactiveProxyAuthEnvironmentVariable = "DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH"; + + private static volatile int s_enableProactiveProxyAuth = -1; + + private static bool EnableProactiveProxyAuth + { + get + { + int enableProactiveProxyAuth = s_enableProactiveProxyAuth; + if (enableProactiveProxyAuth != -1) + { + return enableProactiveProxyAuth != 0; + } + + // First check for the AppContext switch, giving it priority over the environment variable. + if (AppContext.TryGetSwitch(EnableProactiveProxyAuthCtxSwitch, out bool value)) + { + s_enableProactiveProxyAuth = value ? 1 : 0; + } + else + { + // AppContext switch wasn't used. Check the environment variable. + s_enableProactiveProxyAuth = + Environment.GetEnvironmentVariable(EnableProactiveProxyAuthEnvironmentVariable) is string envVar && + (envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase)) ? 1 : 0; + } + + return s_enableProactiveProxyAuth != 0; + } + } + private enum AuthenticationType { Basic, @@ -359,16 +391,16 @@ await TrySetDigestAuthToken(request, challenge.Credential, digestResponse, isPro public static ValueTask SendWithProxyAuthAsync(HttpRequestMessage request, Uri proxyUri, bool async, ICredentials proxyCredentials, bool doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken) { - // For proxy authentication, try to send Basic auth proactively when credentials are available. - // Many proxies require the Proxy-Authorization header on the first request, especially for - // HTTPS CONNECT tunnels where the proxy may close the connection after sending 407. - // This is particularly important for environment variable proxies (HTTP_PROXY/HTTPS_PROXY) - // that include credentials in the URL (e.g., http://user:password@proxy:port). - NetworkCredential? credential = proxyCredentials.GetCredential(proxyUri, BasicScheme); - if (credential != null && credential != CredentialCache.DefaultNetworkCredentials) + // When enabled via AppContext switch or environment variable, send Basic auth proactively + // on the first request. This is needed for proxies that don't send 407 challenges but instead + // drop or reject unauthenticated connections (e.g., some HTTPS CONNECT tunnel proxies). + if (EnableProactiveProxyAuth) { - // Proactively set Basic auth header before sending the request - SetBasicAuthToken(request, credential, isProxyAuth: true); + NetworkCredential? credential = proxyCredentials.GetCredential(proxyUri, BasicScheme); + if (credential != null && credential != CredentialCache.DefaultNetworkCredentials) + { + SetBasicAuthToken(request, credential, isProxyAuth: true); + } } return SendWithAuthAsync(request, proxyUri, async, proxyCredentials, preAuthenticate: false, isProxyAuth: true, doRequestAuth, pool, cancellationToken); diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs index eaa467ef16f3d3..9151454f4f2e31 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs @@ -1,8 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; +using System.Diagnostics; using System.Net.Test.Common; +using System.Text; using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; using Xunit; using Xunit.Abstractions; @@ -13,131 +17,170 @@ public abstract class ProactiveProxyAuthTest : HttpClientHandlerTestBase public ProactiveProxyAuthTest(ITestOutputHelper helper) : base(helper) { } /// - /// Tests that when proxy credentials are provided, the Proxy-Authorization header - /// is sent proactively on the first request without waiting for a 407 challenge. - /// This is important for proxies that don't send 407 responses but instead - /// drop or reject unauthenticated connections. + /// Tests that when proxy credentials are provided and the opt-in switch is enabled, + /// the Proxy-Authorization header is sent proactively on the first request without + /// waiting for a 407 challenge. /// - [Fact] + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] public async Task ProxyAuth_CredentialsProvided_SentProactivelyOnFirstRequest() { - const string username = "testuser"; - const string password = "testpassword"; + const string ExpectedUsername = "testuser"; + const string ExpectedPassword = "testpassword"; - // Create a proxy server that does NOT require authentication (AuthenticationSchemes.None) - // This allows us to verify that credentials are sent proactively even without a 407 challenge - using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => + { + var psi = new ProcessStartInfo(); + psi.Environment.Add("http_proxy", $"http://{proxyUri.Host}:{proxyUri.Port}"); + psi.Environment.Add("DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH", "1"); - await LoopbackServerFactory.CreateClientAndServerAsync( - async uri => + Task serverTask = proxyServer.AcceptConnectionAsync(async connection => { - using HttpClientHandler handler = CreateHttpClientHandler(); - handler.Proxy = new WebProxy(proxyServer.Uri) + List lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); + + // Verify the first request has the Proxy-Authorization header (proactive auth) + string? authHeader = null; + foreach (string line in lines) { - Credentials = new NetworkCredential(username, password) - }; + if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase)) + { + authHeader = line; + break; + } + } - using HttpClient client = CreateHttpClient(handler); + Assert.NotNull(authHeader); + Assert.Contains("Basic", authHeader); - // Make a request - credentials should be sent proactively - HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); - using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + // Verify the credentials are correct + string expectedToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ExpectedUsername}:{ExpectedPassword}")); + Assert.Contains(expectedToken, authHeader); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + }); - // Verify that the proxy received the Proxy-Authorization header on the first request - Assert.Single(proxyServer.Requests); - var proxyRequest = proxyServer.Requests[0]; - Assert.Equal("Basic", proxyRequest.AuthorizationHeaderValueScheme); - Assert.NotNull(proxyRequest.AuthorizationHeaderValueToken); - - // Verify the credentials are correct (Base64 encoded "testuser:testpassword") - string expectedToken = Convert.ToBase64String( - System.Text.Encoding.UTF8.GetBytes($"{username}:{password}")); - Assert.Equal(expectedToken, proxyRequest.AuthorizationHeaderValueToken); - }, - async server => + await RemoteExecutor.Invoke(async (username, password, useVersionString) => { - await server.HandleRequestAsync(content: "Success"); - }); + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + handler.DefaultProxyCredentials = new NetworkCredential(username, password); + + using HttpClient client = CreateHttpClient(handler, useVersionString); + using HttpResponseMessage response = await client.GetAsync("http://destination.test/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }, ExpectedUsername, ExpectedPassword, UseVersion.ToString(), + new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + + await serverTask; + }); } /// - /// Tests that proactive proxy auth works for HTTPS CONNECT tunnels. + /// Tests that without the opt-in switch, credentials are NOT sent proactively + /// (default RFC-compliant behavior). /// - [Fact] - public async Task ProxyAuth_HttpsConnect_CredentialsSentProactively() + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public async Task ProxyAuth_WithoutOptIn_NotSentProactively() { - const string username = "tunneluser"; - const string password = "tunnelpass"; + const string ExpectedUsername = "testuser"; + const string ExpectedPassword = "testpassword"; - using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => + { + var psi = new ProcessStartInfo(); + psi.Environment.Add("http_proxy", $"http://{proxyUri.Host}:{proxyUri.Port}"); + // NOT setting DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH - await LoopbackServerFactory.CreateClientAndServerAsync( - async uri => + Task serverTask = proxyServer.AcceptConnectionAsync(async connection => { - using HttpClientHandler handler = CreateHttpClientHandler(allowAllCertificates: true); - handler.Proxy = new WebProxy(proxyServer.Uri) - { - Credentials = new NetworkCredential(username, password) - }; + List lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); - using HttpClient client = CreateHttpClient(handler); + // Verify the first request does NOT have the Proxy-Authorization header + foreach (string line in lines) + { + Assert.False(line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase), + "First request should not have Proxy-Authorization header without opt-in"); + } - HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); - using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + // Send 407 challenge + await connection.SendResponseAsync(HttpStatusCode.ProxyAuthenticationRequired, + "Proxy-Authenticate: Basic realm=\"Test\"\r\n").ConfigureAwait(false); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // Read the retry request with credentials + lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); - // For HTTPS, the proxy receives a CONNECT request - Assert.True(proxyServer.Requests.Count >= 1); - var connectRequest = proxyServer.Requests[0]; + // Now it should have credentials + string? authHeader = null; + foreach (string line in lines) + { + if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase)) + { + authHeader = line; + break; + } + } + Assert.NotNull(authHeader); + + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + }); - // Verify CONNECT request has proactive auth - Assert.Equal("Basic", connectRequest.AuthorizationHeaderValueScheme); - Assert.NotNull(connectRequest.AuthorizationHeaderValueToken); - }, - async server => + await RemoteExecutor.Invoke(async (username, password, useVersionString) => { - await server.HandleRequestAsync(content: "Secure Success"); - }, - options: new GenericLoopbackOptions { UseSsl = true }); + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + handler.DefaultProxyCredentials = new NetworkCredential(username, password); + + using HttpClient client = CreateHttpClient(handler, useVersionString); + using HttpResponseMessage response = await client.GetAsync("http://destination.test/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }, ExpectedUsername, ExpectedPassword, UseVersion.ToString(), + new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + + await serverTask; + }); } /// - /// Tests that DefaultNetworkCredentials are NOT sent proactively - /// (they are only for NTLM/Negotiate which require challenge-response). + /// Tests that DefaultNetworkCredentials are NOT sent proactively even with the opt-in, + /// as they are only for NTLM/Negotiate which require challenge-response. /// - [Fact] + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] public async Task ProxyAuth_DefaultCredentials_NotSentProactively() { - using LoopbackProxyServer proxyServer = LoopbackProxyServer.Create(); + await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => + { + var psi = new ProcessStartInfo(); + psi.Environment.Add("http_proxy", $"http://{proxyUri.Host}:{proxyUri.Port}"); + psi.Environment.Add("DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH", "1"); - await LoopbackServerFactory.CreateClientAndServerAsync( - async uri => + Task serverTask = proxyServer.AcceptConnectionAsync(async connection => { - using HttpClientHandler handler = CreateHttpClientHandler(); - handler.Proxy = new WebProxy(proxyServer.Uri) + List lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); + + // Verify the first request does NOT have the Proxy-Authorization header + // (DefaultNetworkCredentials should not trigger proactive auth) + foreach (string line in lines) { - Credentials = CredentialCache.DefaultNetworkCredentials - }; + Assert.False(line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase), + "DefaultNetworkCredentials should not trigger proactive Basic auth"); + } - using HttpClient client = CreateHttpClient(handler); + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + }); - HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); - using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + await RemoteExecutor.Invoke(async (useVersionString) => + { + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + handler.DefaultProxyCredentials = CredentialCache.DefaultNetworkCredentials; + + using HttpClient client = CreateHttpClient(handler, useVersionString); + using HttpResponseMessage response = await client.GetAsync("http://destination.test/"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }, UseVersion.ToString(), + new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); - // DefaultNetworkCredentials should NOT result in proactive Basic auth - Assert.Single(proxyServer.Requests); - var proxyRequest = proxyServer.Requests[0]; - Assert.Null(proxyRequest.AuthorizationHeaderValueScheme); - }, - async server => - { - await server.HandleRequestAsync(content: "Success"); - }); + await serverTask; + }); } } @@ -146,11 +189,4 @@ public sealed class ProactiveProxyAuthTest_Http11 : ProactiveProxyAuthTest public ProactiveProxyAuthTest_Http11(ITestOutputHelper helper) : base(helper) { } protected override Version UseVersion => HttpVersion.Version11; } - - [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.SupportsAlpn))] - public sealed class ProactiveProxyAuthTest_Http2 : ProactiveProxyAuthTest - { - public ProactiveProxyAuthTest_Http2(ITestOutputHelper helper) : base(helper) { } - protected override Version UseVersion => HttpVersion.Version20; - } } From 055325b1c5d84dc4ce1a6d6f36fb383c2a697a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 05:20:23 +0000 Subject: [PATCH 4/5] Address PR feedback: move config to GlobalHttpSettings, rename switch Changes based on reviewer feedback: - Move configuration to GlobalHttpSettings.SocketsHttpHandler using RuntimeSettingParser (per ManickaP and MihaZupan) - Rename switch to System.Net.Http.SocketsHttpHandler.ProxyPreAuthenticate with env var DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_PROXYPREAUTHENTICATE - Add test for credentials embedded in proxy URL (http://user:pass@proxy) - Add test for explicit WebProxy credentials - Keep tests for default behavior (no proactive auth) and DefaultNetworkCredentials exclusion --- .../src/System/Net/Http/GlobalHttpSettings.cs | 7 + .../AuthenticationHelper.cs | 34 +---- ...ttpClientHandlerTest.ProactiveProxyAuth.cs | 120 +++++++++++++++++- 3 files changed, 125 insertions(+), 36 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs index e9f9f3dd442b9e..e2ab25ac2dfc9c 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs @@ -98,6 +98,13 @@ private static double GetHttp2StreamWindowScaleThresholdMultiplier() } #endif + // When enabled, send Basic proxy auth proactively on the first request without waiting + // for a 407 challenge. Useful for proxies that don't send 407 responses. + public static bool ProxyPreAuthenticate { get; } = RuntimeSettingParser.QueryRuntimeSettingSwitch( + "System.Net.Http.SocketsHttpHandler.ProxyPreAuthenticate", + "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_PROXYPREAUTHENTICATE", + false); + public static int MaxConnectionsPerServer { get; } = GetMaxConnectionsPerServer(); private static int GetMaxConnectionsPerServer() diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs index 373297e12d3b7c..5431598a6d5398 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs @@ -17,38 +17,6 @@ internal static partial class AuthenticationHelper private const string NtlmScheme = "NTLM"; private const string NegotiateScheme = "Negotiate"; - private const string EnableProactiveProxyAuthCtxSwitch = "System.Net.Http.EnableProactiveProxyAuth"; - private const string EnableProactiveProxyAuthEnvironmentVariable = "DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH"; - - private static volatile int s_enableProactiveProxyAuth = -1; - - private static bool EnableProactiveProxyAuth - { - get - { - int enableProactiveProxyAuth = s_enableProactiveProxyAuth; - if (enableProactiveProxyAuth != -1) - { - return enableProactiveProxyAuth != 0; - } - - // First check for the AppContext switch, giving it priority over the environment variable. - if (AppContext.TryGetSwitch(EnableProactiveProxyAuthCtxSwitch, out bool value)) - { - s_enableProactiveProxyAuth = value ? 1 : 0; - } - else - { - // AppContext switch wasn't used. Check the environment variable. - s_enableProactiveProxyAuth = - Environment.GetEnvironmentVariable(EnableProactiveProxyAuthEnvironmentVariable) is string envVar && - (envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase)) ? 1 : 0; - } - - return s_enableProactiveProxyAuth != 0; - } - } - private enum AuthenticationType { Basic, @@ -394,7 +362,7 @@ public static ValueTask SendWithProxyAuthAsync(HttpRequestM // When enabled via AppContext switch or environment variable, send Basic auth proactively // on the first request. This is needed for proxies that don't send 407 challenges but instead // drop or reject unauthenticated connections (e.g., some HTTPS CONNECT tunnel proxies). - if (EnableProactiveProxyAuth) + if (GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate) { NetworkCredential? credential = proxyCredentials.GetCredential(proxyUri, BasicScheme); if (credential != null && credential != CredentialCache.DefaultNetworkCredentials) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs index 9151454f4f2e31..8cb6abd707a47d 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs @@ -16,6 +16,8 @@ public abstract class ProactiveProxyAuthTest : HttpClientHandlerTestBase { public ProactiveProxyAuthTest(ITestOutputHelper helper) : base(helper) { } + private const string ProxyPreAuthEnvVar = "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_PROXYPREAUTHENTICATE"; + /// /// Tests that when proxy credentials are provided and the opt-in switch is enabled, /// the Proxy-Authorization header is sent proactively on the first request without @@ -31,7 +33,7 @@ await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => { var psi = new ProcessStartInfo(); psi.Environment.Add("http_proxy", $"http://{proxyUri.Host}:{proxyUri.Port}"); - psi.Environment.Add("DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH", "1"); + psi.Environment.Add(ProxyPreAuthEnvVar, "1"); Task serverTask = proxyServer.AcceptConnectionAsync(async connection => { @@ -74,6 +76,61 @@ await RemoteExecutor.Invoke(async (username, password, useVersionString) => }); } + /// + /// Tests that credentials embedded in the proxy URL environment variable are sent proactively. + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public async Task ProxyAuth_CredentialsInEnvironmentUrl_SentProactively() + { + const string ExpectedUsername = "envuser"; + const string ExpectedPassword = "envpass"; + + await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => + { + var psi = new ProcessStartInfo(); + // Credentials embedded in the proxy URL (common pattern for HTTP_PROXY/HTTPS_PROXY) + psi.Environment.Add("http_proxy", $"http://{ExpectedUsername}:{ExpectedPassword}@{proxyUri.Host}:{proxyUri.Port}"); + psi.Environment.Add(ProxyPreAuthEnvVar, "1"); + + Task serverTask = proxyServer.AcceptConnectionAsync(async connection => + { + List lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); + + // Verify the first request has the Proxy-Authorization header + string? authHeader = null; + foreach (string line in lines) + { + if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase)) + { + authHeader = line; + break; + } + } + + Assert.NotNull(authHeader); + Assert.Contains("Basic", authHeader); + + // Verify the credentials are correct + string expectedToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ExpectedUsername}:{ExpectedPassword}")); + Assert.Contains(expectedToken, authHeader); + + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + }); + + await RemoteExecutor.Invoke(async (useVersionString) => + { + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + using HttpClient client = CreateHttpClient(handler, useVersionString); + using HttpResponseMessage response = await client.GetAsync("http://destination.test/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }, UseVersion.ToString(), + new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + + await serverTask; + }); + } + /// /// Tests that without the opt-in switch, credentials are NOT sent proactively /// (default RFC-compliant behavior). @@ -88,7 +145,7 @@ await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => { var psi = new ProcessStartInfo(); psi.Environment.Add("http_proxy", $"http://{proxyUri.Host}:{proxyUri.Port}"); - // NOT setting DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH + // NOT setting ProxyPreAuthEnvVar - default behavior Task serverTask = proxyServer.AcceptConnectionAsync(async connection => { @@ -150,7 +207,7 @@ await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => { var psi = new ProcessStartInfo(); psi.Environment.Add("http_proxy", $"http://{proxyUri.Host}:{proxyUri.Port}"); - psi.Environment.Add("DOTNET_SYSTEM_NET_HTTP_ENABLEPROACTIVEPROXYAUTH", "1"); + psi.Environment.Add(ProxyPreAuthEnvVar, "1"); Task serverTask = proxyServer.AcceptConnectionAsync(async connection => { @@ -182,6 +239,63 @@ await RemoteExecutor.Invoke(async (useVersionString) => await serverTask; }); } + + /// + /// Tests proactive auth with explicit WebProxy credentials (not environment variable). + /// + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public async Task ProxyAuth_ExplicitWebProxyCredentials_SentProactively() + { + const string ExpectedUsername = "proxyuser"; + const string ExpectedPassword = "proxypass"; + + await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => + { + var psi = new ProcessStartInfo(); + psi.Environment.Add(ProxyPreAuthEnvVar, "1"); + + Task serverTask = proxyServer.AcceptConnectionAsync(async connection => + { + List lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); + + // Verify the first request has the Proxy-Authorization header + string? authHeader = null; + foreach (string line in lines) + { + if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase)) + { + authHeader = line; + break; + } + } + + Assert.NotNull(authHeader); + Assert.Contains("Basic", authHeader); + + string expectedToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ExpectedUsername}:{ExpectedPassword}")); + Assert.Contains(expectedToken, authHeader); + + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + }); + + await RemoteExecutor.Invoke(async (proxyUriString, username, password, useVersionString) => + { + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + handler.Proxy = new WebProxy(new Uri(proxyUriString)) + { + Credentials = new NetworkCredential(username, password) + }; + + using HttpClient client = CreateHttpClient(handler, useVersionString); + using HttpResponseMessage response = await client.GetAsync("http://destination.test/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }, proxyUri.ToString(), ExpectedUsername, ExpectedPassword, UseVersion.ToString(), + new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + + await serverTask; + }); + } } public sealed class ProactiveProxyAuthTest_Http11 : ProactiveProxyAuthTest From 81d670127602ea1d84672e44cbd6d8494d26f3a7 Mon Sep 17 00:00:00 2001 From: Paul Tarjan Date: Sat, 7 Feb 2026 23:21:34 -0700 Subject: [PATCH 5/5] Address review: reuse SendWithAuthAsync and add proxy test matrix - Move proxy pre-auth logic into SendWithAuthAsync by handling the isProxyAuth case in the preAuthenticate block, instead of duplicating SetBasicAuthToken in SendWithProxyAuthAsync. Now SendWithProxyAuthAsync simply passes preAuthenticate: GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate. - Guard the PreAuthCredentials cache logic with !isProxyAuth since proxy pre-auth doesn't use the per-pool credential cache. - Add parameterized test ProxyAuth_ProxyAndRequestProtocolCombinations_SentProactively covering 4 proxy/request protocol combinations: HTTP proxy + HTTP request, HTTP proxy + HTTPS request (CONNECT tunnel), HTTPS proxy + HTTP request, HTTPS proxy + HTTPS request (CONNECT tunnel). - Fix existing ProxyAuth_ExplicitWebProxyCredentials_SentProactively to stay within RemoteExecutor's 3-arg limit by encoding credentials in the proxy URI. Co-Authored-By: Claude Opus 4.6 --- .../AuthenticationHelper.cs | 61 +++++----- ...ttpClientHandlerTest.ProactiveProxyAuth.cs | 109 +++++++++++++++++- 2 files changed, 137 insertions(+), 33 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs index 5431598a6d5398..3159740270da10 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.cs @@ -212,29 +212,44 @@ private static ValueTask InnerSendAsync(HttpRequestMessage private static async ValueTask SendWithAuthAsync(HttpRequestMessage request, Uri authUri, bool async, ICredentials credentials, bool preAuthenticate, bool isProxyAuth, bool doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken) { - // If preauth is enabled and this isn't proxy auth, try to get a basic credential from the - // preauth credentials cache, and if successful, set an auth header for it onto the request. + // If preauth is enabled, try to set a Basic auth header proactively on the first request. // Currently we only support preauth for Basic. NetworkCredential? preAuthCredential = null; Uri? preAuthCredentialUri = null; if (preAuthenticate) { - Debug.Assert(pool.PreAuthCredentials != null); - (Uri uriPrefix, NetworkCredential credential)? preAuthCredentialPair; - lock (pool.PreAuthCredentials) + if (isProxyAuth) { - // Just look for basic credentials. If in the future we support preauth - // for other schemes, this will need to search in order of precedence. - Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NegotiateScheme) == null); - Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NtlmScheme) == null); - Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, DigestScheme) == null); - preAuthCredentialPair = pool.PreAuthCredentials.GetCredential(authUri, BasicScheme); + // For proxy pre-authentication, get Basic credentials directly from the + // supplied proxy credentials. This is needed for proxies that don't send 407 + // challenges but instead drop or reject unauthenticated connections. + NetworkCredential? credential = credentials.GetCredential(authUri, BasicScheme); + if (credential != null && credential != CredentialCache.DefaultNetworkCredentials) + { + preAuthCredential = credential; + SetBasicAuthToken(request, credential, isProxyAuth: true); + } } - - if (preAuthCredentialPair != null) + else { - (preAuthCredentialUri, preAuthCredential) = preAuthCredentialPair.Value; - SetBasicAuthToken(request, preAuthCredential, isProxyAuth); + // For request pre-authentication, look up credentials from the preauth cache. + Debug.Assert(pool.PreAuthCredentials != null); + (Uri uriPrefix, NetworkCredential credential)? preAuthCredentialPair; + lock (pool.PreAuthCredentials) + { + // Just look for basic credentials. If in the future we support preauth + // for other schemes, this will need to search in order of precedence. + Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NegotiateScheme) == null); + Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, NtlmScheme) == null); + Debug.Assert(pool.PreAuthCredentials.GetCredential(authUri, DigestScheme) == null); + preAuthCredentialPair = pool.PreAuthCredentials.GetCredential(authUri, BasicScheme); + } + + if (preAuthCredentialPair != null) + { + (preAuthCredentialUri, preAuthCredential) = preAuthCredentialPair.Value; + SetBasicAuthToken(request, preAuthCredential, isProxyAuth); + } } } @@ -299,7 +314,7 @@ await TrySetDigestAuthToken(request, challenge.Credential, digestResponse, isPro SetBasicAuthToken(request, challenge.Credential, isProxyAuth); response = await InnerSendAsync(request, async, isProxyAuth, doRequestAuth, pool, cancellationToken).ConfigureAwait(false); - if (preAuthenticate) + if (preAuthenticate && !isProxyAuth) { switch (response.StatusCode) { @@ -359,19 +374,7 @@ await TrySetDigestAuthToken(request, challenge.Credential, digestResponse, isPro public static ValueTask SendWithProxyAuthAsync(HttpRequestMessage request, Uri proxyUri, bool async, ICredentials proxyCredentials, bool doRequestAuth, HttpConnectionPool pool, CancellationToken cancellationToken) { - // When enabled via AppContext switch or environment variable, send Basic auth proactively - // on the first request. This is needed for proxies that don't send 407 challenges but instead - // drop or reject unauthenticated connections (e.g., some HTTPS CONNECT tunnel proxies). - if (GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate) - { - NetworkCredential? credential = proxyCredentials.GetCredential(proxyUri, BasicScheme); - if (credential != null && credential != CredentialCache.DefaultNetworkCredentials) - { - SetBasicAuthToken(request, credential, isProxyAuth: true); - } - } - - return SendWithAuthAsync(request, proxyUri, async, proxyCredentials, preAuthenticate: false, isProxyAuth: true, doRequestAuth, pool, cancellationToken); + return SendWithAuthAsync(request, proxyUri, async, proxyCredentials, preAuthenticate: GlobalHttpSettings.SocketsHttpHandler.ProxyPreAuthenticate, isProxyAuth: true, doRequestAuth, pool, cancellationToken); } public static ValueTask SendWithRequestAuthAsync(HttpRequestMessage request, bool async, ICredentials credentials, bool preAuthenticate, HttpConnectionPool pool, CancellationToken cancellationToken) diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs index 8cb6abd707a47d..cf2c842b17190f 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.ProactiveProxyAuth.cs @@ -278,24 +278,125 @@ await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); }); - await RemoteExecutor.Invoke(async (proxyUriString, username, password, useVersionString) => + // Encode proxy URI with embedded credentials so we stay within RemoteExecutor's 3-arg limit + string proxyUriWithCreds = $"http://{ExpectedUsername}:{ExpectedPassword}@{proxyUri.Host}:{proxyUri.Port}"; + + await RemoteExecutor.Invoke(async (proxyUriString, useVersionString) => { + var proxyUriParsed = new Uri(proxyUriString); using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); - handler.Proxy = new WebProxy(new Uri(proxyUriString)) + handler.Proxy = new WebProxy(new Uri($"http://{proxyUriParsed.Host}:{proxyUriParsed.Port}")) { - Credentials = new NetworkCredential(username, password) + Credentials = new NetworkCredential( + proxyUriParsed.UserInfo.Split(':')[0], + proxyUriParsed.UserInfo.Split(':')[1]) }; using HttpClient client = CreateHttpClient(handler, useVersionString); using HttpResponseMessage response = await client.GetAsync("http://destination.test/"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }, proxyUri.ToString(), ExpectedUsername, ExpectedPassword, UseVersion.ToString(), + }, proxyUriWithCreds, UseVersion.ToString(), new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); await serverTask; }); } + + /// + /// Tests proactive proxy auth across 4 proxy/request protocol combinations: + /// HTTP proxy + HTTP request, HTTP proxy + HTTPS request (CONNECT tunnel), + /// HTTPS proxy + HTTP request, HTTPS proxy + HTTPS request (CONNECT tunnel). + /// Verifies that the Proxy-Authorization header is present on the first request to the proxy. + /// + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false, false)] // HTTP proxy + HTTP request + [InlineData(false, true)] // HTTP proxy + HTTPS request (CONNECT tunnel) + [InlineData(true, false)] // HTTPS proxy + HTTP request + [InlineData(true, true)] // HTTPS proxy + HTTPS request (CONNECT tunnel) + public async Task ProxyAuth_ProxyAndRequestProtocolCombinations_SentProactively(bool proxyUseSsl, bool requestUseSsl) + { + const string ExpectedUsername = "matrixuser"; + const string ExpectedPassword = "matrixpass"; + string expectedToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ExpectedUsername}:{ExpectedPassword}")); + + var proxyOptions = new LoopbackServer.Options { UseSsl = proxyUseSsl }; + + await LoopbackServer.CreateServerAsync(async (proxyServer, proxyUri) => + { + var psi = new ProcessStartInfo(); + psi.Environment.Add(ProxyPreAuthEnvVar, "1"); + + Task serverTask = proxyServer.AcceptConnectionAsync(async connection => + { + // Read the first request sent to the proxy + List lines = await connection.ReadRequestHeaderAsync().ConfigureAwait(false); + + // Verify the Proxy-Authorization header is present on the first request + string? authHeader = null; + foreach (string line in lines) + { + if (line.StartsWith("Proxy-Authorization:", StringComparison.OrdinalIgnoreCase)) + { + authHeader = line; + break; + } + } + + Assert.NotNull(authHeader); + Assert.Contains("Basic", authHeader); + Assert.Contains(expectedToken, authHeader); + + if (requestUseSsl) + { + // For HTTPS request, the proxy received a CONNECT request. + // Verify it's a CONNECT method. + Assert.StartsWith("CONNECT", lines[0]); + + // Send 200 to establish the tunnel + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + + // Now the client will negotiate TLS through the tunnel. + // Wrap the connection's stream in SSL to act as the destination server. + var sslConnection = await LoopbackServer.Connection.CreateAsync( + null, connection.Stream, new LoopbackServer.Options { UseSsl = true }); + await sslConnection.ReadRequestHeaderAndSendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + } + else + { + // For HTTP request, the proxy received a plain GET request. + Assert.StartsWith("GET", lines[0]); + await connection.SendResponseAsync(HttpStatusCode.OK).ConfigureAwait(false); + } + }); + + string requestScheme = requestUseSsl ? "https" : "http"; + + // Encode proxy URI with embedded credentials so we stay within RemoteExecutor's 3-arg limit + string proxyScheme = proxyUseSsl ? "https" : "http"; + string proxyUriWithCreds = $"{proxyScheme}://{ExpectedUsername}:{ExpectedPassword}@{proxyUri.Host}:{proxyUri.Port}"; + + await RemoteExecutor.Invoke(async (proxyUriString, reqScheme, useVersionString) => + { + var proxyUriParsed = new Uri(proxyUriString); + using HttpClientHandler handler = CreateHttpClientHandler(useVersionString); + handler.Proxy = new WebProxy(new Uri($"{proxyUriParsed.Scheme}://{proxyUriParsed.Host}:{proxyUriParsed.Port}")) + { + Credentials = new NetworkCredential( + proxyUriParsed.UserInfo.Split(':')[0], + proxyUriParsed.UserInfo.Split(':')[1]) + }; + + using HttpClient client = CreateHttpClient(handler, useVersionString); + using HttpResponseMessage response = await client.GetAsync($"{reqScheme}://destination.test/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }, proxyUriWithCreds, requestScheme, UseVersion.ToString(), + new RemoteInvokeOptions { StartInfo = psi }).DisposeAsync(); + + await serverTask; + }, proxyOptions); + } } public sealed class ProactiveProxyAuthTest_Http11 : ProactiveProxyAuthTest