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 TestLoopbackAsync_MemberData() => + from scheme in new[] { "socks4", "socks4a", "socks5" } + from useSsl in BoolValues + from useAuth in BoolValues + from host in Hosts(scheme) + select new object[] { scheme, useSsl, useAuth, host }; + + [Theory] + [MemberData(nameof(TestLoopbackAsync_MemberData))] + public async Task TestLoopbackAsync(string scheme, bool useSsl, bool useAuth, string host) + { + if (useSsl && UseVersion == HttpVersion.Version20 && !PlatformDetection.SupportsAlpn) + { + return; + } + + await LoopbackServerFactory.CreateClientAndServerAsync( + async uri => + { + using LoopbackSocksServer proxy = useAuth ? LoopbackSocksServer.Create("DOTNET", "424242") : LoopbackSocksServer.Create(); + using HttpClientHandler handler = CreateHttpClientHandler(); + using HttpClient client = CreateHttpClient(handler); + + handler.Proxy = new WebProxy($"{scheme}://localhost:{proxy.Port}"); + handler.ServerCertificateCustomValidationCallback = TestHelper.AllowAllCertificates; + + if (useAuth) + { + handler.Proxy.Credentials = new NetworkCredential("DOTNET", "424242"); + } + + uri = new UriBuilder(uri) { Host = host }.Uri; + + HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + + using HttpResponseMessage response = await client.SendAsync(TestAsync, request); + string responseString = await response.Content.ReadAsStringAsync(); + Assert.Equal("Echo", responseString); + }, + async server => await server.HandleRequestAsync(content: "Echo"), + options: new GenericLoopbackOptions + { + UseSsl = useSsl, + Address = host == "::1" ? IPAddress.IPv6Loopback : IPAddress.Loopback + }); + } + + public static IEnumerable TestExceptionalAsync_MemberData() + { + foreach (string scheme in new[] { "socks4", "socks4a" }) + { + yield return new object[] { scheme, "[::1]", false, null, "SOCKS4 does not support IPv6 addresses." }; + yield return new object[] { scheme, "localhost", true, null, "Failed to authenticate with the SOCKS server." }; + yield return new object[] { scheme, "localhost", true, new NetworkCredential("bad_username", "bad_password"), "Failed to authenticate with the SOCKS server." }; + yield return new object[] { scheme, "localhost", true, new NetworkCredential(new string('a', 256), "foo"), "Encoding the UserName took more than the maximum of 255 bytes." }; + } + + yield return new object[] { "socks4", new string('a', 256), false, null, "Failed to resolve the destination host to an IPv4 address." }; + + foreach (string scheme in new[] { "socks4a", "socks5" }) + { + yield return new object[] { scheme, new string('a', 256), false, null, "Encoding the host took more than the maximum of 255 bytes." }; + } + + yield return new object[] { "socks5", "localhost", true, null, "SOCKS server did not return a suitable authentication method." }; + yield return new object[] { "socks5", "localhost", true, new NetworkCredential("bad_username", "bad_password"), "Failed to authenticate with the SOCKS server." }; + yield return new object[] { "socks5", "localhost", true, new NetworkCredential(new string('a', 256), "foo"), "Encoding the UserName took more than the maximum of 255 bytes." }; + yield return new object[] { "socks5", "localhost", true, new NetworkCredential("foo", new string('a', 256)), "Encoding the Password took more than the maximum of 255 bytes." }; + } + + [Theory] + [MemberData(nameof(TestExceptionalAsync_MemberData))] + public async Task TestExceptionalAsync(string scheme, string host, bool useAuth, ICredentials? credentials, string exceptionMessage) + { + using LoopbackSocksServer proxy = useAuth ? LoopbackSocksServer.Create("DOTNET", "424242") : LoopbackSocksServer.Create(); + using HttpClientHandler handler = CreateHttpClientHandler(); + using HttpClient client = CreateHttpClient(handler); + + handler.Proxy = new WebProxy($"{scheme}://localhost:{proxy.Port}") + { + Credentials = credentials + }; + + HttpRequestMessage request = CreateRequest(HttpMethod.Get, new Uri($"http://{host}/"), UseVersion, exactVersion: true); + + // SocksException is not public + var ex = await Assert.ThrowsAnyAsync(() => client.SendAsync(TestAsync, request)); + Assert.Equal(exceptionMessage, ex.Message); + Assert.Equal("SocksException", ex.GetType().Name); + } + } + + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public sealed class SocksProxyTest_Http1_Async : SocksProxyTest + { + public SocksProxyTest_Http1_Async(ITestOutputHelper helper) : base(helper) { } + protected override Version UseVersion => HttpVersion.Version11; + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public sealed class SocksProxyTest_Http1_Sync : SocksProxyTest + { + public SocksProxyTest_Http1_Sync(ITestOutputHelper helper) : base(helper) { } + protected override Version UseVersion => HttpVersion.Version11; + protected override bool TestAsync => false; + } + + [ConditionalClass(typeof(PlatformDetection), nameof(PlatformDetection.IsNotBrowser))] + public sealed class SocksProxyTest_Http2 : SocksProxyTest + { + public SocksProxyTest_Http2(ITestOutputHelper helper) : base(helper) { } + protected override Version UseVersion => HttpVersion.Version20; + } +} diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj index 96531b22b6aae..95f4737a1660d 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj @@ -192,6 +192,11 @@ + + + + +