From 4a42983323d132039c054d9eecdb651521d89380 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 17 Jun 2026 00:46:35 +0200 Subject: [PATCH] Allow creating H2C connections over HTTP CONNECT proxy tunnels --- .../HttpConnectionPool.Http2.cs | 2 +- .../ConnectionPool/HttpConnectionPool.cs | 4 +- .../SocketsHttpHandler/HttpConnectionKind.cs | 4 +- .../HttpConnectionPoolManager.cs | 8 ++- .../FunctionalTests/SocketsHttpHandlerTest.cs | 56 +++++++++++++++++++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs index 3141ba44a8d628..c17904c1fc0c57 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs @@ -64,7 +64,7 @@ public byte[] Http2AltSvcOriginUri private bool TryGetPooledHttp2Connection(HttpRequestMessage request, [NotNullWhen(true)] out Http2Connection? connection, out HttpConnectionWaiter? waiter) { - Debug.Assert(_kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel || _kind == HttpConnectionKind.Http || _kind == HttpConnectionKind.SocksTunnel || _kind == HttpConnectionKind.SslSocksTunnel); + Debug.Assert(_kind is HttpConnectionKind.Https or HttpConnectionKind.SslProxyTunnel or HttpConnectionKind.Http or HttpConnectionKind.ProxyTunnel or HttpConnectionKind.SocksTunnel or HttpConnectionKind.SslSocksTunnel); // Look for a usable connection. while (true) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs index 4eec4dd1012ebf..065582103897cd 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs @@ -130,7 +130,9 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK Debug.Assert(sslHostName == null); Debug.Assert(proxyUri != null); - _http2Enabled = false; + // A CONNECT tunnel to the origin server behaves like a direct connection once established, + // so cleartext HTTP/2 (h2c) can be used over it. HTTP/1.1 WebSockets keep working because + // the WebSocket upgrade request uses HTTP/1.1 and never attempts HTTP/2. _http3Enabled = false; break; diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionKind.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionKind.cs index 5e0b13a4ce8918..3c6f3eff87e6a7 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionKind.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionKind.cs @@ -7,8 +7,8 @@ internal enum HttpConnectionKind : byte { Http, // Non-secure connection with no proxy. Https, // Secure connection with no proxy. - Proxy, // HTTP proxy usage for non-secure (HTTP) requests. - ProxyTunnel, // Non-secure websocket (WS) connection using CONNECT tunneling through proxy. + Proxy, // HTTP proxy usage for non-secure (HTTP) requests using HTTP/1.1. + ProxyTunnel, // Non-secure (HTTP/WS) connection using CONNECT tunneling through proxy. Used for cleartext HTTP/2 (h2c) and non-secure WebSockets. SslProxyTunnel, // HTTP proxy usage for secure (HTTPS/WSS) requests using SSL and proxy CONNECT. ProxyConnect, // Connection used for proxy CONNECT. Tunnel will be established on top of this. SocksTunnel, // SOCKS proxy usage for HTTP requests. diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs index b99f9e719c897e..f8c08eb4754d03 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs @@ -272,9 +272,13 @@ private HttpConnectionKey GetConnectionKey(HttpRequestMessage request, Uri? prox } else if (sslHostName == null) { - if (HttpUtilities.IsNonSecureWebSocketScheme(uri.Scheme)) + // Both non-secure WebSockets (WS) and cleartext HTTP/2 (h2c) need a CONNECT tunnel to the destination, + // because they can't be expressed using the absolute-form request line an HTTP proxy expects. + // h2c is only tunneled when HTTP/2 is required or preferred; requests that allow downgrading to + // HTTP/1.1 (RequestVersionOrLower) keep using the shared HTTP/1.1 proxy pool below. + if (HttpUtilities.IsNonSecureWebSocketScheme(uri.Scheme) || + (request.Version.Major == 2 && request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower)) { - // Non-secure websocket connection through proxy to the destination. return new HttpConnectionKey(HttpConnectionKind.ProxyTunnel, uri.IdnHost, uri.Port, null, proxyUri, identity); } else diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index e0758a3704dcf9..16463469d10986 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -831,6 +831,62 @@ await LoopbackServer.CreateClientAndServerAsync(async uri => }), new LoopbackServer.Options { UseSsl = true }); } + + [ConditionalTheory(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))] + [InlineData("1.1", HttpVersionPolicy.RequestVersionExact, false)] + [InlineData("1.1", HttpVersionPolicy.RequestVersionOrHigher, false)] + [InlineData("2.0", HttpVersionPolicy.RequestVersionExact, false)] + [InlineData("2.0", HttpVersionPolicy.RequestVersionOrHigher, false)] + [InlineData("2.0", HttpVersionPolicy.RequestVersionOrLower, false)] + [InlineData("1.1", HttpVersionPolicy.RequestVersionExact, true)] + [InlineData("2.0", HttpVersionPolicy.RequestVersionExact, true)] + [InlineData("2.0", HttpVersionPolicy.RequestVersionOrLower, true)] + public async Task ProxiedRequest_UsesConnectTunnelForHttpsAndCleartextHttp2(string version, HttpVersionPolicy versionPolicy, bool useSsl) + { + Version requestVersion = Version.Parse(version); + + if (useSsl && requestVersion.Major == 2 && !PlatformDetection.SupportsAlpn) + { + // Negotiating HTTP/2 over TLS requires ALPN. + return; + } + + // HTTPS requests always tunnel through the proxy using CONNECT. Cleartext (http://) requests are + // only tunneled when they require HTTP/2 (h2c, which can't use the proxy's absolute-form request line); + // otherwise they are forwarded using an absolute-form HTTP/1.1 request line. + bool useHttp2 = requestVersion.Major == 2 && (versionPolicy != HttpVersionPolicy.RequestVersionOrLower || useSsl); + bool expectConnectTunnel = useSsl || useHttp2; + Version expectedResponseVersion = useHttp2 ? HttpVersion.Version20 : HttpVersion.Version11; + + using LoopbackProxyServer proxy = LoopbackProxyServer.Create(); + + await GetFactoryForVersion(expectedResponseVersion).CreateClientAndServerAsync( + async uri => + { + using SocketsHttpHandler handler = CreateSocketsHttpHandler(allowAllCertificates: true); + handler.Proxy = new UseSpecifiedUriWebProxy(proxy.Uri); + using HttpClient client = CreateHttpClient(handler); + + HttpRequestMessage request = new(HttpMethod.Get, uri) + { + Version = requestVersion, + VersionPolicy = versionPolicy + }; + + using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedResponseVersion, response.Version); + Assert.Equal("Echo", await response.Content.ReadAsStringAsync()); + }, + async server => await server.HandleRequestAsync(content: "Echo"), + options: new GenericLoopbackOptions { UseSsl = useSsl }); + + Assert.NotEmpty(proxy.Requests); + + // A tunneled request must have used a CONNECT request; a forwarded request must have used an + // absolute-form HTTP/1.1 request line. + Assert.All(proxy.Requests, r => Assert.StartsWith(expectConnectTunnel ? "CONNECT " : "GET http://", r.RequestLine)); + } } [SkipOnPlatform(TestPlatforms.Wasi, "SocketsHttpHandler is not supported on WASI")]