diff --git a/src/libraries/System.Net.Http/src/Resources/Strings.resx b/src/libraries/System.Net.Http/src/Resources/Strings.resx
index 501437eb2b0e5..897207df43ddc 100644
--- a/src/libraries/System.Net.Http/src/Resources/Strings.resx
+++ b/src/libraries/System.Net.Http/src/Resources/Strings.resx
@@ -406,7 +406,7 @@
Client certificate was not found in the personal (\"MY\") certificate store. In UWP, client certificates are only supported if they have been added to that certificate store.
- Only the 'http' scheme is allowed for proxies.
+ Only the 'http', 'socks4', 'socks4a' and 'socks5' schemes are allowed for proxies.
Request headers must contain only ASCII characters.
@@ -606,6 +606,33 @@
Synchronous reads are not supported, use ReadAsync instead.
+
+ Failed to authenticate with the SOCKS server.
+
+
+ SOCKS server returned an unknown address type.
+
+
+ SOCKS server failed to connect to the destination.
+
+
+ SOCKS4 does not support IPv6 addresses.
+
+
+ SOCKS server did not return a suitable authentication method.
+
+
+ Failed to resolve the destination host to an IPv4 address.
+
+
+ Unexpected SOCKS protocol version. Required {0}, got {1}.
+
+
+ Encoding the {0} took more than the maximum of 255 bytes.
+
+
+ SOCKS server requested username & password authentication.
+
The proxy tunnel request to proxy '{0}' failed with status code '{1}'."
diff --git a/src/libraries/System.Net.Http/src/System.Net.Http.csproj b/src/libraries/System.Net.Http/src/System.Net.Http.csproj
index 201d0acd63e5d..8b2180e467486 100644
--- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj
+++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj
@@ -180,6 +180,8 @@
+
+
internal static bool IsSecureWebSocketScheme(string scheme) =>
string.Equals(scheme, "wss", StringComparison.OrdinalIgnoreCase);
+ internal static bool IsSupportedProxyScheme(string scheme) =>
+ string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) || IsSocksScheme(scheme);
+
+ internal static bool IsSocksScheme(string scheme) =>
+ string.Equals(scheme, "socks5", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(scheme, "socks4a", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(scheme, "socks4", StringComparison.OrdinalIgnoreCase);
+
// Always specify TaskScheduler.Default to prevent us from using a user defined TaskScheduler.Current.
//
// Since we're not doing any CPU and/or I/O intensive operations, continue on the same thread.
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 873994bd03324..5e0b13a4ce891 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
@@ -10,6 +10,8 @@ internal enum HttpConnectionKind : byte
Proxy, // HTTP proxy usage for non-secure (HTTP) requests.
ProxyTunnel, // Non-secure websocket (WS) connection using CONNECT tunneling through proxy.
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.
+ ProxyConnect, // Connection used for proxy CONNECT. Tunnel will be established on top of this.
+ SocksTunnel, // SOCKS proxy usage for HTTP requests.
+ SslSocksTunnel // SOCKS proxy usage for HTTPS requests.
}
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
index de2481eec6e1d..ad40acd7e887e 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
@@ -182,8 +182,17 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK
_http3Enabled = false;
break;
+ case HttpConnectionKind.SocksTunnel:
+ case HttpConnectionKind.SslSocksTunnel:
+ Debug.Assert(host != null);
+ Debug.Assert(port != 0);
+ Debug.Assert(proxyUri != null);
+
+ _http3Enabled = false; // TODO: SOCKS supports UDP and may be used for HTTP3
+ break;
+
default:
- Debug.Fail("Unkown HttpConnectionKind in HttpConnectionPool.ctor");
+ Debug.Fail("Unknown HttpConnectionKind in HttpConnectionPool.ctor");
break;
}
@@ -293,7 +302,7 @@ private static SslClientAuthenticationOptions ConstructSslOptions(HttpConnection
public HttpAuthority? OriginAuthority => _originAuthority;
public HttpConnectionSettings Settings => _poolManager.Settings;
public HttpConnectionKind Kind => _kind;
- public bool IsSecure => _kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel;
+ public bool IsSecure => _kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel || _kind == HttpConnectionKind.SslSocksTunnel;
public Uri? ProxyUri => _proxyUri;
public ICredentials? ProxyCredentials => _poolManager.ProxyCredentials;
public byte[]? HostHeaderValueBytes => _hostHeaderValueBytes;
@@ -315,10 +324,10 @@ public byte[] Http2AltSvcOriginUri
Debug.Assert(_originAuthority != null);
sb
- .Append(_kind == HttpConnectionKind.Https ? "https://" : "http://")
+ .Append(IsSecure ? "https://" : "http://")
.Append(_originAuthority.IdnHost);
- if (_originAuthority.Port != (_kind == HttpConnectionKind.Https ? DefaultHttpsPort : DefaultHttpPort))
+ if (_originAuthority.Port != (IsSecure ? DefaultHttpsPort : DefaultHttpPort))
{
sb
.Append(':')
@@ -523,7 +532,7 @@ public byte[] Http2AltSvcOriginUri
private async ValueTask<(HttpConnectionBase connection, bool isNewConnection)>
GetHttp2ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
{
- Debug.Assert(_kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel || _kind == HttpConnectionKind.Http);
+ Debug.Assert(_kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel || _kind == HttpConnectionKind.Http || _kind == HttpConnectionKind.SocksTunnel || _kind == HttpConnectionKind.SslSocksTunnel);
// See if we have an HTTP2 connection
Http2Connection? http2Connection = GetExistingHttp2Connection();
@@ -579,7 +588,7 @@ public byte[] Http2AltSvcOriginUri
sslStream = stream as SslStream;
- if (_kind == HttpConnectionKind.Http)
+ if (!IsSecure)
{
http2Connection = await ConstructHttp2ConnectionAsync(stream, request, cancellationToken).ConfigureAwait(false);
@@ -1101,7 +1110,7 @@ internal void BlocklistAuthority(HttpAuthority badAuthority)
Debug.Assert(_altSvcBlocklistTimerCancellation != null);
if (added)
{
- _ = Task.Delay(AltSvcBlocklistTimeoutInMilliseconds)
+ _ = Task.Delay(AltSvcBlocklistTimeoutInMilliseconds)
.ContinueWith(t =>
{
lock (altSvcBlocklist)
@@ -1217,6 +1226,14 @@ public ValueTask SendAsync(HttpRequestMessage request, bool
case HttpConnectionKind.SslProxyTunnel:
stream = await EstablishProxyTunnelAsync(async, request.HasHeaders ? request.Headers : null, cancellationToken).ConfigureAwait(false);
break;
+
+ case HttpConnectionKind.SocksTunnel:
+ case HttpConnectionKind.SslSocksTunnel:
+ Debug.Assert(_originAuthority != null);
+ Debug.Assert(_proxyUri != null);
+ (socket, stream) = await ConnectToTcpHostAsync(_proxyUri.IdnHost, _proxyUri.Port, request, async, cancellationToken).ConfigureAwait(false);
+ await SocksHelper.EstablishSocksTunnelAsync(stream, _originAuthority.IdnHost, _originAuthority.Port, _proxyUri, ProxyCredentials, async, cancellationToken).ConfigureAwait(false);
+ break;
}
Debug.Assert(stream != null);
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 b8fafcca13614..2024ac852b2d7 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
@@ -279,8 +279,20 @@ private HttpConnectionKey GetConnectionKey(HttpRequestMessage request, Uri? prox
if (proxyUri != null)
{
- Debug.Assert(HttpUtilities.IsSupportedNonSecureScheme(proxyUri.Scheme));
- if (sslHostName == null)
+ Debug.Assert(HttpUtilities.IsSupportedProxyScheme(proxyUri.Scheme));
+ if (HttpUtilities.IsSocksScheme(proxyUri.Scheme))
+ {
+ // Socks proxy
+ if (sslHostName != null)
+ {
+ return new HttpConnectionKey(HttpConnectionKind.SslSocksTunnel, uri.IdnHost, uri.Port, sslHostName, proxyUri, identity);
+ }
+ else
+ {
+ return new HttpConnectionKey(HttpConnectionKind.SocksTunnel, uri.IdnHost, uri.Port, null, proxyUri, identity);
+ }
+ }
+ else if (sslHostName == null)
{
if (HttpUtilities.IsNonSecureWebSocketScheme(uri.Scheme))
{
@@ -391,7 +403,7 @@ public ValueTask SendAsync(HttpRequestMessage request, bool
if (NetEventSource.Log.IsEnabled()) NetEventSource.Error(this, $"Exception from {_proxy.GetType().Name}.GetProxy({request.RequestUri}): {ex}");
}
- if (proxyUri != null && proxyUri.Scheme != UriScheme.Http)
+ if (proxyUri != null && !HttpUtilities.IsSupportedProxyScheme(proxyUri.Scheme))
{
throw new NotSupportedException(SR.net_http_invalid_proxy_scheme);
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocksException.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocksException.cs
new file mode 100644
index 0000000000000..c9312c23d57c3
--- /dev/null
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocksException.cs
@@ -0,0 +1,14 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+
+namespace System.Net.Http
+{
+ internal class SocksException : IOException
+ {
+ public SocksException(string message) : base(message) { }
+
+ public SocksException(string message, Exception innerException) : base(message, innerException) { }
+ }
+}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocksHelper.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocksHelper.cs
new file mode 100644
index 0000000000000..8e7408410e2f4
--- /dev/null
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocksHelper.cs
@@ -0,0 +1,371 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace System.Net.Http
+{
+ internal static class SocksHelper
+ {
+ // Largest possible message size is 513 bytes (Socks5 username & password auth)
+ private const int BufferSize = 513;
+ private const int ProtocolVersion4 = 4;
+ private const int ProtocolVersion5 = 5;
+ private const int SubnegotiationVersion = 1; // Socks5 username & password auth
+ private const byte METHOD_NO_AUTH = 0;
+ private const byte METHOD_USERNAME_PASSWORD = 2;
+ private const byte CMD_CONNECT = 1;
+ private const byte ATYP_IPV4 = 1;
+ private const byte ATYP_DOMAIN_NAME = 3;
+ private const byte ATYP_IPV6 = 4;
+ private const byte Socks5_Success = 0;
+ private const byte Socks4_Success = 90;
+ private const byte Socks4_AuthFailed = 93;
+
+ public static async ValueTask EstablishSocksTunnelAsync(Stream stream, string host, int port, Uri proxyUri, ICredentials? proxyCredentials, bool async, CancellationToken cancellationToken)
+ {
+ using (cancellationToken.Register(s => ((Stream)s!).Dispose(), stream))
+ {
+ try
+ {
+ NetworkCredential? credentials = proxyCredentials?.GetCredential(proxyUri, proxyUri.Scheme);
+
+ if (string.Equals(proxyUri.Scheme, "socks5", StringComparison.OrdinalIgnoreCase))
+ {
+ await EstablishSocks5TunnelAsync(stream, host, port, proxyUri, credentials, async).ConfigureAwait(false);
+ }
+ else if (string.Equals(proxyUri.Scheme, "socks4a", StringComparison.OrdinalIgnoreCase))
+ {
+ await EstablishSocks4TunnelAsync(stream, isVersion4a: true, host, port, proxyUri, credentials, async, cancellationToken).ConfigureAwait(false);
+ }
+ else if (string.Equals(proxyUri.Scheme, "socks4", StringComparison.OrdinalIgnoreCase))
+ {
+ await EstablishSocks4TunnelAsync(stream, isVersion4a: false, host, port, proxyUri, credentials, async, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ Debug.Fail("Bad socks version.");
+ }
+ }
+ catch
+ {
+ stream.Dispose();
+ throw;
+ }
+ }
+ }
+
+ private static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string host, int port, Uri proxyUri, NetworkCredential? credentials, bool async)
+ {
+ byte[] buffer = ArrayPool.Shared.Rent(BufferSize);
+ try
+ {
+ // https://tools.ietf.org/html/rfc1928
+
+ // +----+----------+----------+
+ // |VER | NMETHODS | METHODS |
+ // +----+----------+----------+
+ // | 1 | 1 | 1 to 255 |
+ // +----+----------+----------+
+ buffer[0] = ProtocolVersion5;
+ if (credentials is null)
+ {
+ buffer[1] = 1;
+ buffer[2] = METHOD_NO_AUTH;
+ }
+ else
+ {
+ buffer[1] = 2;
+ buffer[2] = METHOD_NO_AUTH;
+ buffer[3] = METHOD_USERNAME_PASSWORD;
+ }
+ await WriteAsync(stream, buffer.AsMemory(0, buffer[1] + 2), async).ConfigureAwait(false);
+
+ // +----+--------+
+ // |VER | METHOD |
+ // +----+--------+
+ // | 1 | 1 |
+ // +----+--------+
+ await ReadToFillAsync(stream, buffer.AsMemory(0, 2), async).ConfigureAwait(false);
+ VerifyProtocolVersion(ProtocolVersion5, buffer[0]);
+
+ switch (buffer[1])
+ {
+ case METHOD_NO_AUTH:
+ // continue
+ break;
+
+ case METHOD_USERNAME_PASSWORD:
+ {
+ // https://tools.ietf.org/html/rfc1929
+ if (credentials is null)
+ {
+ // If the server is behaving well, it shouldn't pick username and password auth
+ // because we don't claim to support it when we don't have credentials.
+ // Just being defensive here.
+ throw new SocksException(SR.net_socks_auth_required);
+ }
+
+ // +----+------+----------+------+----------+
+ // |VER | ULEN | UNAME | PLEN | PASSWD |
+ // +----+------+----------+------+----------+
+ // | 1 | 1 | 1 to 255 | 1 | 1 to 255 |
+ // +----+------+----------+------+----------+
+ buffer[0] = SubnegotiationVersion;
+ byte usernameLength = EncodeString(credentials.UserName, buffer.AsSpan(2), nameof(credentials.UserName));
+ buffer[1] = usernameLength;
+ byte passwordLength = EncodeString(credentials.Password, buffer.AsSpan(3 + usernameLength), nameof(credentials.Password));
+ buffer[2 + usernameLength] = passwordLength;
+ await WriteAsync(stream, buffer.AsMemory(0, 3 + usernameLength + passwordLength), async).ConfigureAwait(false);
+
+ // +----+--------+
+ // |VER | STATUS |
+ // +----+--------+
+ // | 1 | 1 |
+ // +----+--------+
+ await ReadToFillAsync(stream, buffer.AsMemory(0, 2), async).ConfigureAwait(false);
+ if (buffer[0] != SubnegotiationVersion || buffer[1] != Socks5_Success)
+ {
+ throw new SocksException(SR.net_socks_auth_failed);
+ }
+ break;
+ }
+
+ default:
+ throw new SocksException(SR.net_socks_no_auth_method);
+ }
+
+
+ // +----+-----+-------+------+----------+----------+
+ // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+ // +----+-----+-------+------+----------+----------+
+ // | 1 | 1 | X'00' | 1 | Variable | 2 |
+ // +----+-----+-------+------+----------+----------+
+ buffer[0] = ProtocolVersion5;
+ buffer[1] = CMD_CONNECT;
+ buffer[2] = 0;
+ int addressLength;
+
+ if (IPAddress.TryParse(host, out IPAddress? hostIP))
+ {
+ if (hostIP.AddressFamily == AddressFamily.InterNetwork)
+ {
+ buffer[3] = ATYP_IPV4;
+ hostIP.TryWriteBytes(buffer.AsSpan(4), out int bytesWritten);
+ Debug.Assert(bytesWritten == 4);
+ addressLength = 4;
+ }
+ else
+ {
+ Debug.Assert(hostIP.AddressFamily == AddressFamily.InterNetworkV6);
+ buffer[3] = ATYP_IPV6;
+ hostIP.TryWriteBytes(buffer.AsSpan(4), out int bytesWritten);
+ Debug.Assert(bytesWritten == 16);
+ addressLength = 16;
+ }
+ }
+ else
+ {
+ buffer[3] = ATYP_DOMAIN_NAME;
+ byte hostLength = EncodeString(host, buffer.AsSpan(5), nameof(host));
+ buffer[4] = hostLength;
+ addressLength = hostLength + 1;
+ }
+
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)port);
+
+ await WriteAsync(stream, buffer.AsMemory(0, addressLength + 6), async).ConfigureAwait(false);
+
+ // +----+-----+-------+------+----------+----------+
+ // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT |
+ // +----+-----+-------+------+----------+----------+
+ // | 1 | 1 | X'00' | 1 | Variable | 2 |
+ // +----+-----+-------+------+----------+----------+
+ await ReadToFillAsync(stream, buffer.AsMemory(0, 5), async).ConfigureAwait(false);
+ VerifyProtocolVersion(ProtocolVersion5, buffer[0]);
+ if (buffer[1] != Socks5_Success)
+ {
+ throw new SocksException(SR.net_socks_connection_failed);
+ }
+ int bytesToSkip = buffer[3] switch
+ {
+ ATYP_IPV4 => 5,
+ ATYP_IPV6 => 17,
+ ATYP_DOMAIN_NAME => buffer[4] + 2,
+ _ => throw new SocksException(SR.net_socks_bad_address_type)
+ };
+ await ReadToFillAsync(stream, buffer.AsMemory(0, bytesToSkip), async).ConfigureAwait(false);
+ // response address not used
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool isVersion4a, string host, int port, Uri proxyUri, NetworkCredential? credentials, bool async, CancellationToken cancellationToken)
+ {
+ byte[] buffer = ArrayPool.Shared.Rent(BufferSize);
+ try
+ {
+ // https://www.openssh.com/txt/socks4.protocol
+
+ // +----+----+----+----+----+----+----+----+----+----+....+----+
+ // | VN | CD | DSTPORT | DSTIP | USERID |NULL|
+ // +----+----+----+----+----+----+----+----+----+----+....+----+
+ // 1 1 2 4 variable 1
+ buffer[0] = ProtocolVersion4;
+ buffer[1] = CMD_CONNECT;
+
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(2), (ushort)port);
+
+ IPAddress? ipv4Address = null;
+ if (IPAddress.TryParse(host, out IPAddress? hostIP))
+ {
+ if (hostIP.AddressFamily == AddressFamily.InterNetwork)
+ {
+ ipv4Address = hostIP;
+ }
+ else if (hostIP.IsIPv4MappedToIPv6)
+ {
+ ipv4Address = hostIP.MapToIPv4();
+ }
+ else
+ {
+ throw new SocksException(SR.net_socks_ipv6_notsupported);
+ }
+ }
+ else if (!isVersion4a)
+ {
+ // Socks4 does not support domain names - try to resolve it here
+ IPAddress[] addresses;
+ try
+ {
+ addresses = async
+ ? await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationToken).ConfigureAwait(false)
+ : Dns.GetHostAddresses(host, AddressFamily.InterNetwork);
+ }
+ catch (Exception ex)
+ {
+ throw new SocksException(SR.net_socks_no_ipv4_address, ex);
+ }
+
+ if (addresses.Length == 0)
+ {
+ throw new SocksException(SR.net_socks_no_ipv4_address);
+ }
+
+ ipv4Address = addresses[0];
+ }
+
+ if (ipv4Address is null)
+ {
+ Debug.Assert(isVersion4a);
+ buffer[4] = 0;
+ buffer[5] = 0;
+ buffer[6] = 0;
+ buffer[7] = 255;
+ }
+ else
+ {
+ ipv4Address.TryWriteBytes(buffer.AsSpan(4), out int bytesWritten);
+ Debug.Assert(bytesWritten == 4);
+ }
+
+ byte usernameLength = EncodeString(credentials?.UserName, buffer.AsSpan(8), nameof(credentials.UserName));
+ buffer[8 + usernameLength] = 0;
+ int totalLength = 9 + usernameLength;
+
+ if (ipv4Address is null)
+ {
+ // https://www.openssh.com/txt/socks4a.protocol
+ byte hostLength = EncodeString(host, buffer.AsSpan(totalLength), nameof(host));
+ buffer[totalLength + hostLength] = 0;
+ totalLength += hostLength + 1;
+ }
+
+ await WriteAsync(stream, buffer.AsMemory(0, totalLength), async).ConfigureAwait(false);
+
+ // +----+----+----+----+----+----+----+----+
+ // | VN | CD | DSTPORT | DSTIP |
+ // +----+----+----+----+----+----+----+----+
+ // 1 1 2 4
+ await ReadToFillAsync(stream, buffer.AsMemory(0, 8), async).ConfigureAwait(false);
+
+ switch (buffer[1])
+ {
+ case Socks4_Success:
+ // Nothing to do
+ break;
+ case Socks4_AuthFailed:
+ throw new SocksException(SR.net_socks_auth_failed);
+ default:
+ throw new SocksException(SR.net_socks_connection_failed);
+ }
+ // response address not used
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName)
+ {
+ try
+ {
+ return checked((byte)Encoding.UTF8.GetBytes(chars, buffer));
+ }
+ catch
+ {
+ Debug.Assert(Encoding.UTF8.GetByteCount(chars) > 255);
+ throw new SocksException(SR.Format(SR.net_socks_string_too_long, parameterName));
+ }
+ }
+
+ private static void VerifyProtocolVersion(byte expected, byte version)
+ {
+ if (expected != version)
+ {
+ throw new SocksException(SR.Format(SR.net_socks_unexpected_version, expected, version));
+ }
+ }
+
+ private static ValueTask WriteAsync(Stream stream, Memory buffer, bool async)
+ {
+ if (async)
+ {
+ return stream.WriteAsync(buffer);
+ }
+ else
+ {
+ stream.Write(buffer.Span);
+ return default;
+ }
+ }
+
+ private static async ValueTask ReadToFillAsync(Stream stream, Memory buffer, bool async)
+ {
+ while (buffer.Length != 0)
+ {
+ int bytesRead = async
+ ? await stream.ReadAsync(buffer).ConfigureAwait(false)
+ : stream.Read(buffer.Span);
+
+ if (bytesRead == 0)
+ {
+ throw new IOException(SR.net_http_invalid_response_premature_eof);
+ }
+
+ buffer = buffer[bytesRead..];
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/Socks/LoopbackSocksServer.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/Socks/LoopbackSocksServer.cs
new file mode 100644
index 0000000000000..0649f4d6bafd0
--- /dev/null
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/Socks/LoopbackSocksServer.cs
@@ -0,0 +1,365 @@
+// 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.Concurrent;
+using System.IO;
+using System.Net.Sockets;
+using System.Net.Test.Common;
+using System.Runtime.ExceptionServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace System.Net.Http.Functional.Tests.Socks
+{
+ ///
+ /// Provides a test-only SOCKS4/5 proxy.
+ ///
+ internal class LoopbackSocksServer : IDisposable
+ {
+ private readonly Socket _listener;
+ private readonly ManualResetEvent _serverStopped;
+ private bool _disposed;
+
+ private int _connections;
+ public int Connections => _connections;
+
+ public int Port { get; }
+
+ private string? _username, _password;
+
+ private LoopbackSocksServer(string? username = null, string? password = null)
+ {
+ if (password != null && username == null)
+ {
+ throw new ArgumentException("Password must be used together with username.", nameof(password));
+ }
+
+ _username = username;
+ _password = password;
+
+ _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ _listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ _listener.Listen(int.MaxValue);
+
+ var ep = (IPEndPoint)_listener.LocalEndPoint;
+ Port = ep.Port;
+
+ _serverStopped = new ManualResetEvent(false);
+ }
+
+ private void Start()
+ {
+ Task.Run(async () =>
+ {
+ var activeTasks = new ConcurrentDictionary();
+
+ try
+ {
+ while (true)
+ {
+ Socket s = await _listener.AcceptAsync().ConfigureAwait(false);
+
+ var connectionTask = Task.Run(async () =>
+ {
+ try
+ {
+ await ProcessConnection(s).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ EventSourceTestLogging.Log.TestAncillaryError(ex);
+ }
+ });
+
+ activeTasks.TryAdd(connectionTask, 0);
+ _ = connectionTask.ContinueWith(t => activeTasks.TryRemove(connectionTask, out _), TaskContinuationOptions.ExecuteSynchronously);
+ }
+ }
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.OperationAborted)
+ {
+ // caused during Dispose() to cancel the loop. ignore.
+ }
+ catch (Exception ex)
+ {
+ EventSourceTestLogging.Log.TestAncillaryError(ex);
+ }
+
+ try
+ {
+ await Task.WhenAll(activeTasks.Keys).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ EventSourceTestLogging.Log.TestAncillaryError(ex);
+ }
+
+ _serverStopped.Set();
+ });
+ }
+
+ private async Task ProcessConnection(Socket s)
+ {
+ Interlocked.Increment(ref _connections);
+
+ using (var ns = new NetworkStream(s, ownsSocket: true))
+ {
+ await ProcessRequest(s, ns).ConfigureAwait(false);
+ }
+ }
+
+ private async Task ProcessRequest(Socket clientSocket, NetworkStream ns)
+ {
+ int version = await ns.ReadByteAsync().ConfigureAwait(false);
+
+ await (version switch
+ {
+ 4 => ProcessSocks4Request(clientSocket, ns),
+ 5 => ProcessSocks5Request(clientSocket, ns),
+ -1 => throw new Exception("Early EOF"),
+ _ => throw new Exception("Bad request version")
+ }).ConfigureAwait(false);
+ }
+
+ private async Task ProcessSocks4Request(Socket clientSocket, NetworkStream ns)
+ {
+ byte[] buffer = new byte[7];
+ await ReadToFillAsync(ns, buffer).ConfigureAwait(false);
+
+ if (buffer[0] != 1)
+ throw new Exception("Only CONNECT is supported.");
+
+ int port = (buffer[1] << 8) + buffer[2];
+ // formats ip into string to ensure we get the correct order
+ string remoteHost = $"{buffer[3]}.{buffer[4]}.{buffer[5]}.{buffer[6]}";
+
+ byte[] usernameBuffer = new byte[1024];
+ int usernameBytes = 0;
+ while (true)
+ {
+ int usernameByte = await ns.ReadByteAsync().ConfigureAwait(false);
+ if (usernameByte == 0)
+ break;
+ if (usernameByte == -1)
+ throw new Exception("Early EOF");
+ usernameBuffer[usernameBytes++] = (byte)usernameByte;
+ }
+
+ if (remoteHost.StartsWith("0.0.0") && remoteHost != "0.0.0.0")
+ {
+ byte[] hostBuffer = new byte[1024];
+ int hostnameBytes = 0;
+
+ while (true)
+ {
+ int b = await ns.ReadByteAsync().ConfigureAwait(false);
+ if (b == -1)
+ throw new Exception("Early EOF");
+ if (b == 0)
+ break;
+
+ hostBuffer[hostnameBytes++] = (byte)b;
+ }
+
+ remoteHost = Encoding.UTF8.GetString(hostBuffer.AsSpan(0, hostnameBytes));
+ }
+
+ if (_username != null)
+ {
+ string username = Encoding.UTF8.GetString(usernameBuffer.AsSpan(0, usernameBytes));
+ if (username != _username)
+ {
+ ns.WriteByte(4);
+ buffer[0] = 93;
+ await ns.WriteAsync(buffer).ConfigureAwait(false);
+ return;
+ }
+ }
+
+ ns.WriteByte(4);
+ buffer[0] = 90;
+ await ns.WriteAsync(buffer).ConfigureAwait(false);
+
+ await RelayHttpTraffic(clientSocket, ns, remoteHost, port).ConfigureAwait(false);
+ }
+
+ private async Task ProcessSocks5Request(Socket clientSocket, NetworkStream ns)
+ {
+ int nMethods = await ns.ReadByteAsync().ConfigureAwait(false);
+ if (nMethods == -1)
+ throw new Exception("Early EOF");
+
+ byte[] buffer = new byte[1024];
+ await ReadToFillAsync(ns, buffer.AsMemory(0, nMethods)).ConfigureAwait(false);
+
+ byte expectedAuthMethod = _username == null ? (byte)0 : (byte)2;
+ if (!buffer.AsSpan(0, nMethods).Contains(expectedAuthMethod))
+ {
+ await ns.WriteAsync(new byte[] { 5, 0xFF }).ConfigureAwait(false);
+ return;
+ }
+
+ await ns.WriteAsync(new byte[] { 5, expectedAuthMethod }).ConfigureAwait(false);
+
+ if (_username != null)
+ {
+ if (await ns.ReadByteAsync().ConfigureAwait(false) != 1)
+ throw new Exception("Bad subnegotiation version.");
+
+ int usernameLength = await ns.ReadByteAsync().ConfigureAwait(false);
+ await ReadToFillAsync(ns, buffer.AsMemory(0, usernameLength)).ConfigureAwait(false);
+ string username = Encoding.UTF8.GetString(buffer.AsSpan(0, usernameLength));
+
+ int passwordLength = await ns.ReadByteAsync().ConfigureAwait(false);
+ await ReadToFillAsync(ns, buffer.AsMemory(0, passwordLength)).ConfigureAwait(false);
+ string password = Encoding.UTF8.GetString(buffer.AsSpan(0, passwordLength));
+
+ if (username != _username || password != _password)
+ {
+ await ns.WriteAsync(new byte[] { 1, 1 }).ConfigureAwait(false);
+ throw new Exception("Invalid credentials.");
+ }
+
+ await ns.WriteAsync(new byte[] { 1, 0 }).ConfigureAwait(false);
+ }
+
+ await ReadToFillAsync(ns, buffer.AsMemory(0, 4)).ConfigureAwait(false);
+ if (buffer[0] != 5)
+ throw new Exception("Bad protocol version.");
+ if (buffer[1] != 1)
+ throw new Exception("Only CONNECT is supported.");
+
+ string remoteHost;
+ switch (buffer[3])
+ {
+ case 1:
+ await ReadToFillAsync(ns, buffer.AsMemory(0, 4)).ConfigureAwait(false);
+ remoteHost = new IPAddress(buffer.AsSpan(0, 4)).ToString();
+ break;
+ case 4:
+ await ReadToFillAsync(ns, buffer.AsMemory(0, 16)).ConfigureAwait(false);
+ remoteHost = new IPAddress(buffer.AsSpan(0, 16)).ToString();
+ break;
+ case 3:
+ int length = await ns.ReadByteAsync().ConfigureAwait(false);
+ if (length == -1)
+ throw new Exception("Early EOF");
+ await ReadToFillAsync(ns, buffer.AsMemory(0, length)).ConfigureAwait(false);
+ remoteHost = Encoding.UTF8.GetString(buffer.AsSpan(0, length));
+ break;
+
+ default:
+ throw new Exception("Unknown address type.");
+ }
+
+ await ReadToFillAsync(ns, buffer.AsMemory(0, 2)).ConfigureAwait(false);
+ int port = (buffer[0] << 8) + buffer[1];
+
+ await ns.WriteAsync(new byte[] { 5, 0, 0, 1, 0, 0, 0, 0, 0, 0 }).ConfigureAwait(false);
+
+ await RelayHttpTraffic(clientSocket, ns, remoteHost, port).ConfigureAwait(false);
+ }
+
+ private async Task RelayHttpTraffic(Socket clientSocket, NetworkStream clientStream, string remoteHost, int remotePort)
+ {
+ // Open connection to destination server.
+ using var serverSocket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ await serverSocket.ConnectAsync(remoteHost, remotePort).ConfigureAwait(false);
+ var serverStream = new NetworkStream(serverSocket);
+
+ // Relay traffic to/from client and destination server.
+ Task clientCopyTask = Task.Run(async () =>
+ {
+ try
+ {
+ await clientStream.CopyToAsync(serverStream).ConfigureAwait(false);
+ serverSocket.Shutdown(SocketShutdown.Send);
+ }
+ catch (Exception ex)
+ {
+ HandleExceptions(ex);
+ }
+ });
+
+ Task serverCopyTask = Task.Run(async () =>
+ {
+ try
+ {
+ await serverStream.CopyToAsync(clientStream).ConfigureAwait(false);
+ clientSocket.Shutdown(SocketShutdown.Send);
+ }
+ catch (Exception ex)
+ {
+ HandleExceptions(ex);
+ }
+ });
+
+ await Task.WhenAll(new[] { clientCopyTask, serverCopyTask }).ConfigureAwait(false);
+
+ /// Closes sockets to cause both tasks to end, and eats connection reset/aborted errors.
+ void HandleExceptions(Exception ex)
+ {
+ SocketError sockErr = (ex.InnerException as SocketException)?.SocketErrorCode ?? SocketError.Success;
+
+ // If aborted, the other task failed and is asking this task to end.
+ if (sockErr == SocketError.OperationAborted)
+ {
+ return;
+ }
+
+ // Ask the other task to end by disposing, causing OperationAborted.
+ try
+ {
+ clientSocket.Close();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ try
+ {
+ serverSocket.Close();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+
+ // Eat reset/abort.
+ if (sockErr != SocketError.ConnectionReset && sockErr != SocketError.ConnectionAborted)
+ {
+ ExceptionDispatchInfo.Capture(ex).Throw();
+ }
+ }
+ }
+
+ private async ValueTask ReadToFillAsync(Stream stream, Memory buffer)
+ {
+ while (!buffer.IsEmpty)
+ {
+ int bytesRead = await stream.ReadAsync(buffer).ConfigureAwait(false);
+ if (bytesRead == 0)
+ throw new Exception("Incomplete request");
+
+ buffer = buffer.Slice(bytesRead);
+ }
+ }
+
+ public static LoopbackSocksServer Create(string? username = null, string? password = null)
+ {
+ var server = new LoopbackSocksServer(username, password);
+ server.Start();
+
+ return server;
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _listener.Dispose();
+ _serverStopped.WaitOne();
+ _disposed = true;
+ }
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/Socks/SocksProxyTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/Socks/SocksProxyTest.cs
new file mode 100644
index 0000000000000..b82d091a90d91
--- /dev/null
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/Socks/SocksProxyTest.cs
@@ -0,0 +1,136 @@
+// 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.IO;
+using System.Linq;
+using System.Net.Test.Common;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace System.Net.Http.Functional.Tests.Socks
+{
+ public abstract class SocksProxyTest : HttpClientHandlerTestBase
+ {
+ public SocksProxyTest(ITestOutputHelper helper) : base(helper) { }
+
+ private static string[] Hosts(string socksScheme) => socksScheme == "socks5"
+ ? new[] { "localhost", "127.0.0.1", "::1" }
+ : new[] { "localhost", "127.0.0.1" };
+
+ public static IEnumerable