diff --git a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc index 998a9dd14a0..45d1e360fa2 100644 --- a/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc +++ b/documentation/jetty-documentation/src/main/asciidoc/programming-guide/client/http/client-http-proxy.adoc @@ -16,7 +16,12 @@ Jetty's `HttpClient` can be configured to use proxies to connect to destinations. -Two types of proxies are available out of the box: a HTTP proxy (provided by class `org.eclipse.jetty.client.HttpProxy`) and a SOCKS 4 proxy (provided by class `org.eclipse.jetty.client.Socks4Proxy`). +These types of proxies are available out of the box: + +* HTTP proxy (provided by class `org.eclipse.jetty.client.HttpProxy`) +* SOCKS 4 proxy (provided by class `org.eclipse.jetty.client.Socks4Proxy`) +* xref:pg-client-http-proxy-socks5[SOCKS 5 proxy] (provided by class `org.eclipse.jetty.client.Socks5Proxy`) + Other implementations may be written by subclassing `ProxyConfiguration.Proxy`. The following is a typical configuration: @@ -30,14 +35,26 @@ You specify the proxy host and proxy port, and optionally also the addresses tha Configured in this way, `HttpClient` makes requests to the HTTP proxy (for plain-text HTTP requests) or establishes a tunnel via HTTP `CONNECT` (for encrypted HTTPS requests). -Proxying is supported for HTTP/1.1 and HTTP/2. +Proxying is supported for any version of the HTTP protocol. + +[[pg-client-http-proxy-socks5]] +===== SOCKS5 Proxy Support + +SOCKS 5 (defined in link:https://datatracker.ietf.org/doc/html/rfc1928[RFC 1928]) offers choices for authentication methods and supports IPv6 (things that SOCKS 4 does not support). + +A typical SOCKS 5 proxy configuration with the username/password authentication method is the following: + +[source,java,indent=0] +---- +include::../../{doc_code}/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java[tag=proxySocks5] +---- [[pg-client-http-proxy-authentication]] -===== Proxy Authentication Support +===== HTTP Proxy Authentication Support -Jetty's `HttpClient` supports proxy authentication in the same way it supports xref:pg-client-http-authentication[server authentication]. +Jetty's `HttpClient` supports HTTP proxy authentication in the same way it supports xref:pg-client-http-authentication[server authentication]. -In the example below, the proxy requires `BASIC` authentication, but the server requires `DIGEST` authentication, and therefore: +In the example below, the HTTP proxy requires `BASIC` authentication, but the server requires `DIGEST` authentication, and therefore: [source,java,indent=0] ---- diff --git a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java index 65d7717d75e..2954db45189 100644 --- a/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java +++ b/documentation/jetty-documentation/src/main/java/org/eclipse/jetty/docs/programming/client/http/HTTPClientDocs.java @@ -34,6 +34,8 @@ import org.eclipse.jetty.client.HttpDestination; import org.eclipse.jetty.client.HttpProxy; import org.eclipse.jetty.client.ProxyConfiguration; import org.eclipse.jetty.client.RoundRobinConnectionPool; +import org.eclipse.jetty.client.Socks5; +import org.eclipse.jetty.client.Socks5Proxy; import org.eclipse.jetty.client.api.Authentication; import org.eclipse.jetty.client.api.AuthenticationStore; import org.eclipse.jetty.client.api.ContentResponse; @@ -665,6 +667,30 @@ public class HTTPClientDocs // end::proxy[] } + public void proxySocks5() throws Exception + { + HttpClient httpClient = new HttpClient(); + httpClient.start(); + + // tag::proxySocks5[] + Socks5Proxy proxy = new Socks5Proxy("proxyHost", 8888); + String socks5User = "jetty"; + String socks5Pass = "secret"; + var socks5AuthenticationFactory = new Socks5.UsernamePasswordAuthenticationFactory(socks5User, socks5Pass); + // Add the authentication method to the proxy. + proxy.putAuthenticationFactory(socks5AuthenticationFactory); + + // Do not proxy requests for localhost:8080. + proxy.getExcludedAddresses().add("localhost:8080"); + + // Add the new proxy to the list of proxies already registered. + ProxyConfiguration proxyConfig = httpClient.getProxyConfiguration(); + proxyConfig.addProxy(proxy); + + ContentResponse response = httpClient.GET("http://domain.com/path"); + // end::proxySocks5[] + } + public void proxyAuthentication() throws Exception { HttpClient httpClient = new HttpClient(); diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java new file mode 100644 index 00000000000..1d71f2e098a --- /dev/null +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5.java @@ -0,0 +1,238 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.client; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

Helper class for SOCKS5 proxying.

+ * + * @see Socks5Proxy + */ +public class Socks5 +{ + /** + * The SOCKS protocol version: {@value}. + */ + public static final byte VERSION = 0x05; + /** + * The SOCKS5 {@code CONNECT} command used in SOCKS5 connect requests. + */ + public static final byte COMMAND_CONNECT = 0x01; + /** + * The reserved byte value: {@value}. + */ + public static final byte RESERVED = 0x00; + /** + * The address type for IPv4 used in SOCKS5 connect requests and responses. + */ + public static final byte ADDRESS_TYPE_IPV4 = 0x01; + /** + * The address type for domain names used in SOCKS5 connect requests and responses. + */ + public static final byte ADDRESS_TYPE_DOMAIN = 0x03; + /** + * The address type for IPv6 used in SOCKS5 connect requests and responses. + */ + public static final byte ADDRESS_TYPE_IPV6 = 0x04; + + private Socks5() + { + } + + /** + *

A SOCKS5 authentication method.

+ *

Implementations should send and receive the bytes that + * are specific to the particular authentication method.

+ */ + public interface Authentication + { + /** + *

Performs the authentication send and receive bytes + * exchanges specific for this {@link Authentication}.

+ * + * @param endPoint the {@link EndPoint} to send to and receive from the SOCKS5 server + * @param callback the callback to complete when the authentication is complete + */ + void authenticate(EndPoint endPoint, Callback callback); + + /** + * A factory for {@link Authentication}s. + */ + interface Factory + { + /** + * @return the authentication method defined by RFC 1928 + */ + byte getMethod(); + + /** + * @return a new {@link Authentication} + */ + Authentication newAuthentication(); + } + } + + /** + *

The implementation of the {@code NO AUTH} authentication method defined in + * RFC 1928.

+ */ + public static class NoAuthenticationFactory implements Authentication.Factory + { + public static final byte METHOD = 0x00; + + @Override + public byte getMethod() + { + return METHOD; + } + + @Override + public Authentication newAuthentication() + { + return (endPoint, callback) -> callback.succeeded(); + } + } + + /** + *

The implementation of the {@code USERNAME/PASSWORD} authentication method defined in + * RFC 1929.

+ */ + public static class UsernamePasswordAuthenticationFactory implements Authentication.Factory + { + public static final byte METHOD = 0x02; + public static final byte VERSION = 0x01; + private static final Logger LOG = LoggerFactory.getLogger(UsernamePasswordAuthenticationFactory.class); + + private final String userName; + private final String password; + private final Charset charset; + + public UsernamePasswordAuthenticationFactory(String userName, String password) + { + this(userName, password, StandardCharsets.US_ASCII); + } + + public UsernamePasswordAuthenticationFactory(String userName, String password, Charset charset) + { + this.userName = Objects.requireNonNull(userName); + this.password = Objects.requireNonNull(password); + this.charset = Objects.requireNonNull(charset); + } + + @Override + public byte getMethod() + { + return METHOD; + } + + @Override + public Authentication newAuthentication() + { + return new UsernamePasswordAuthentication(this); + } + + private static class UsernamePasswordAuthentication implements Authentication, Callback + { + private final ByteBuffer byteBuffer = BufferUtil.allocate(2); + private final UsernamePasswordAuthenticationFactory factory; + private EndPoint endPoint; + private Callback callback; + + private UsernamePasswordAuthentication(UsernamePasswordAuthenticationFactory factory) + { + this.factory = factory; + } + + @Override + public void authenticate(EndPoint endPoint, Callback callback) + { + this.endPoint = endPoint; + this.callback = callback; + + byte[] userNameBytes = factory.userName.getBytes(factory.charset); + byte[] passwordBytes = factory.password.getBytes(factory.charset); + ByteBuffer byteBuffer = ByteBuffer.allocate(3 + userNameBytes.length + passwordBytes.length) + .put(VERSION) + .put((byte)userNameBytes.length) + .put(userNameBytes) + .put((byte)passwordBytes.length) + .put(passwordBytes) + .flip(); + endPoint.write(Callback.from(this::authenticationSent, this::failed), byteBuffer); + } + + private void authenticationSent() + { + if (LOG.isDebugEnabled()) + LOG.debug("Written SOCKS5 username/password authentication request"); + endPoint.fillInterested(this); + } + + @Override + public void succeeded() + { + try + { + int filled = endPoint.fill(byteBuffer); + if (filled < 0) + throw new ClosedChannelException(); + if (byteBuffer.remaining() < 2) + { + endPoint.fillInterested(this); + return; + } + if (LOG.isDebugEnabled()) + LOG.debug("Received SOCKS5 username/password authentication response"); + byte version = byteBuffer.get(); + if (version != VERSION) + throw new IOException("Unsupported username/password authentication version: " + version); + byte status = byteBuffer.get(); + if (status != 0) + throw new IOException("SOCK5 username/password authentication failure"); + if (LOG.isDebugEnabled()) + LOG.debug("SOCKS5 username/password authentication succeeded"); + callback.succeeded(); + } + catch (Throwable x) + { + failed(x); + } + } + + @Override + public void failed(Throwable x) + { + callback.failed(x); + } + + @Override + public InvocationType getInvocationType() + { + return InvocationType.NON_BLOCKING; + } + } + } +} diff --git a/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java new file mode 100644 index 00000000000..d70791e20ee --- /dev/null +++ b/jetty-client/src/main/java/org/eclipse/jetty/client/Socks5Proxy.java @@ -0,0 +1,416 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.client; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeoutException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jetty.client.ProxyConfiguration.Proxy; +import org.eclipse.jetty.client.Socks5.NoAuthenticationFactory; +import org.eclipse.jetty.client.api.Connection; +import org.eclipse.jetty.io.AbstractConnection; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.URIUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + *

Client-side proxy configuration for SOCKS5, defined by + * RFC 1928.

+ *

Multiple authentication methods are supported via + * {@link #putAuthenticationFactory(Socks5.Authentication.Factory)}. + * By default only the {@link Socks5.NoAuthenticationFactory NO AUTH} + * authentication method is configured. + * The {@link Socks5.UsernamePasswordAuthenticationFactory USERNAME/PASSWORD} + * is available to applications but must be explicitly configured and + * added.

+ */ +public class Socks5Proxy extends Proxy +{ + private static final Logger LOG = LoggerFactory.getLogger(Socks5Proxy.class); + + private final Map authentications = new LinkedHashMap<>(); + + public Socks5Proxy(String host, int port) + { + this(new Origin.Address(host, port), false); + } + + public Socks5Proxy(Origin.Address address, boolean secure) + { + super(address, secure, null, null); + putAuthenticationFactory(new NoAuthenticationFactory()); + } + + /** + *

Provides this class with the given SOCKS5 authentication method.

+ * + * @param authenticationFactory the SOCKS5 authentication factory + * @return the previous authentication method of the same type, or {@code null} + * if there was none of that type already present + */ + public Socks5.Authentication.Factory putAuthenticationFactory(Socks5.Authentication.Factory authenticationFactory) + { + return authentications.put(authenticationFactory.getMethod(), authenticationFactory); + } + + /** + *

Removes the authentication of the given {@code method}.

+ * + * @param method the authentication method to remove + */ + public Socks5.Authentication.Factory removeAuthenticationFactory(byte method) + { + return authentications.remove(method); + } + + @Override + public ClientConnectionFactory newClientConnectionFactory(ClientConnectionFactory connectionFactory) + { + return new Socks5ProxyClientConnectionFactory(connectionFactory); + } + + private class Socks5ProxyClientConnectionFactory implements ClientConnectionFactory + { + private final ClientConnectionFactory connectionFactory; + + private Socks5ProxyClientConnectionFactory(ClientConnectionFactory connectionFactory) + { + this.connectionFactory = connectionFactory; + } + + public org.eclipse.jetty.io.Connection newConnection(EndPoint endPoint, Map context) + { + HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); + Executor executor = destination.getHttpClient().getExecutor(); + Socks5ProxyConnection connection = new Socks5ProxyConnection(endPoint, executor, connectionFactory, context, authentications); + return customize(connection, context); + } + } + + private static class Socks5ProxyConnection extends AbstractConnection implements org.eclipse.jetty.io.Connection.UpgradeFrom + { + private static final Pattern IPv4_PATTERN = Pattern.compile("(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})"); + + // SOCKS5 response max length is 262 bytes. + private final ByteBuffer byteBuffer = BufferUtil.allocate(512); + private final ClientConnectionFactory connectionFactory; + private final Map context; + private final Map authentications; + private State state = State.HANDSHAKE; + + private Socks5ProxyConnection(EndPoint endPoint, Executor executor, ClientConnectionFactory connectionFactory, Map context, Map authentications) + { + super(endPoint, executor); + this.connectionFactory = connectionFactory; + this.context = context; + this.authentications = Map.copyOf(authentications); + } + + @Override + public ByteBuffer onUpgradeFrom() + { + return BufferUtil.copy(byteBuffer); + } + + @Override + public void onOpen() + { + super.onOpen(); + sendHandshake(); + } + + private void sendHandshake() + { + try + { + // +-------------+--------------------+------------------+ + // | version (1) | num of methods (1) | methods (1..255) | + // +-------------+--------------------+------------------+ + int size = authentications.size(); + ByteBuffer byteBuffer = ByteBuffer.allocate(1 + 1 + size) + .put(Socks5.VERSION) + .put((byte)size); + authentications.keySet().forEach(byteBuffer::put); + byteBuffer.flip(); + getEndPoint().write(Callback.from(this::handshakeSent, this::fail), byteBuffer); + } + catch (Throwable x) + { + fail(x); + } + } + + private void handshakeSent() + { + if (LOG.isDebugEnabled()) + LOG.debug("Written SOCKS5 handshake request"); + state = State.HANDSHAKE; + fillInterested(); + } + + private void fail(Throwable x) + { + if (LOG.isDebugEnabled()) + LOG.debug("SOCKS5 failure", x); + getEndPoint().close(x); + @SuppressWarnings("unchecked") + Promise promise = (Promise)this.context.get(HttpClientTransport.HTTP_CONNECTION_PROMISE_CONTEXT_KEY); + promise.failed(x); + } + + @Override + public boolean onIdleExpired() + { + fail(new TimeoutException("Idle timeout expired")); + return false; + } + + @Override + public void onFillable() + { + try + { + switch (state) + { + case HANDSHAKE: + receiveHandshake(); + break; + case CONNECT: + receiveConnect(); + break; + default: + throw new IllegalStateException(); + } + } + catch (Throwable x) + { + fail(x); + } + } + + private void receiveHandshake() throws IOException + { + // +-------------+------------+ + // | version (1) | method (1) | + // +-------------+------------+ + int filled = getEndPoint().fill(byteBuffer); + if (filled < 0) + throw new ClosedChannelException(); + if (byteBuffer.remaining() < 2) + { + fillInterested(); + return; + } + + if (LOG.isDebugEnabled()) + LOG.debug("Received SOCKS5 handshake response {}", BufferUtil.toDetailString(byteBuffer)); + + byte version = byteBuffer.get(); + if (version != Socks5.VERSION) + throw new IOException("Unsupported SOCKS5 version: " + version); + + byte method = byteBuffer.get(); + if (method == -1) + throw new IOException("Unacceptable SOCKS5 authentication methods"); + + Socks5.Authentication.Factory factory = authentications.get(method); + if (factory == null) + throw new IOException("Unknown SOCKS5 authentication method: " + method); + + factory.newAuthentication().authenticate(getEndPoint(), Callback.from(this::sendConnect, this::fail)); + } + + private void sendConnect() + { + try + { + // +-------------+-------------+--------------+------------------+------------------------+----------+ + // | version (1) | command (1) | reserved (1) | address type (1) | address bytes (4..255) | port (2) | + // +-------------+-------------+--------------+------------------+------------------------+----------+ + HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); + Origin.Address address = destination.getOrigin().getAddress(); + String host = address.getHost(); + short port = (short)address.getPort(); + + ByteBuffer byteBuffer; + Matcher matcher = IPv4_PATTERN.matcher(host); + if (matcher.matches()) + { + byteBuffer = ByteBuffer.allocate(10) + .put(Socks5.VERSION) + .put(Socks5.COMMAND_CONNECT) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_IPV4); + for (int i = 1; i <= 4; ++i) + { + byteBuffer.put(Byte.parseByte(matcher.group(i))); + } + byteBuffer.putShort(port) + .flip(); + } + else if (URIUtil.isValidHostRegisteredName(host)) + { + byte[] bytes = host.getBytes(StandardCharsets.US_ASCII); + if (bytes.length > 255) + throw new IOException("Invalid host name: " + host); + byteBuffer = ByteBuffer.allocate(7 + bytes.length) + .put(Socks5.VERSION) + .put(Socks5.COMMAND_CONNECT) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_DOMAIN) + .put((byte)bytes.length) + .put(bytes) + .putShort(port) + .flip(); + } + else + { + // Assume IPv6. + byte[] bytes = InetAddress.getByName(host).getAddress(); + byteBuffer = ByteBuffer.allocate(22) + .put(Socks5.VERSION) + .put(Socks5.COMMAND_CONNECT) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_IPV6) + .put(bytes) + .putShort(port) + .flip(); + } + + getEndPoint().write(Callback.from(this::connectSent, this::fail), byteBuffer); + } + catch (Throwable x) + { + fail(x); + } + } + + private void connectSent() + { + if (LOG.isDebugEnabled()) + LOG.debug("Written SOCKS5 connect request"); + state = State.CONNECT; + fillInterested(); + } + + private void receiveConnect() throws IOException + { + // +-------------+-----------+--------------+------------------+------------------------+----------+ + // | version (1) | reply (1) | reserved (1) | address type (1) | address bytes (4..255) | port (2) | + // +-------------+-----------+--------------+------------------+------------------------+----------+ + int filled = getEndPoint().fill(byteBuffer); + if (filled < 0) + throw new ClosedChannelException(); + if (byteBuffer.remaining() < 5) + { + fillInterested(); + return; + } + byte addressType = byteBuffer.get(3); + int length = 6; + if (addressType == Socks5.ADDRESS_TYPE_IPV4) + length += 4; + else if (addressType == Socks5.ADDRESS_TYPE_DOMAIN) + length += 1 + (byteBuffer.get(4) & 0xFF); + else if (addressType == Socks5.ADDRESS_TYPE_IPV6) + length += 16; + else + throw new IOException("Invalid SOCKS5 address type: " + addressType); + if (byteBuffer.remaining() < length) + { + fillInterested(); + return; + } + + if (LOG.isDebugEnabled()) + LOG.debug("Received SOCKS5 connect response {}", BufferUtil.toDetailString(byteBuffer)); + + // We have all the SOCKS5 bytes. + byte version = byteBuffer.get(); + if (version != Socks5.VERSION) + throw new IOException("Unsupported SOCKS5 version: " + version); + + byte status = byteBuffer.get(); + switch (status) + { + case 0: + // Consume the buffer before upgrading to the tunnel. + byteBuffer.position(length); + tunnel(); + break; + case 1: + throw new IOException("SOCKS5 general failure"); + case 2: + throw new IOException("SOCKS5 connection not allowed"); + case 3: + throw new IOException("SOCKS5 network unreachable"); + case 4: + throw new IOException("SOCKS5 host unreachable"); + case 5: + throw new IOException("SOCKS5 connection refused"); + case 6: + throw new IOException("SOCKS5 timeout expired"); + case 7: + throw new IOException("SOCKS5 unsupported command"); + case 8: + throw new IOException("SOCKS5 unsupported address"); + default: + throw new IOException("SOCKS5 unknown status: " + status); + } + } + + private void tunnel() + { + try + { + HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); + // Don't want to do DNS resolution here. + InetSocketAddress address = InetSocketAddress.createUnresolved(destination.getHost(), destination.getPort()); + context.put(ClientConnector.REMOTE_SOCKET_ADDRESS_CONTEXT_KEY, address); + ClientConnectionFactory connectionFactory = this.connectionFactory; + if (destination.isSecure()) + connectionFactory = destination.newSslClientConnectionFactory(null, connectionFactory); + var newConnection = connectionFactory.newConnection(getEndPoint(), context); + getEndPoint().upgrade(newConnection); + if (LOG.isDebugEnabled()) + LOG.debug("SOCKS5 tunnel established: {} over {}", this, newConnection); + } + catch (Throwable x) + { + fail(x); + } + } + + private enum State + { + HANDSHAKE, CONNECT + } + } +} diff --git a/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java b/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java new file mode 100644 index 00000000000..ef955e2e05c --- /dev/null +++ b/jetty-client/src/test/java/org/eclipse/jetty/client/Socks5ProxyTest.java @@ -0,0 +1,1065 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.client; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; + +import org.eclipse.jetty.client.Socks5.UsernamePasswordAuthenticationFactory; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; +import org.eclipse.jetty.client.util.FutureResponseListener; +import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpTester; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class Socks5ProxyTest +{ + private ServerSocketChannel proxy; + private HttpClient client; + + @BeforeEach + public void prepare() throws Exception + { + proxy = ServerSocketChannel.open(); + proxy.bind(new InetSocketAddress("127.0.0.1", 0)); + + ClientConnector connector = new ClientConnector(); + QueuedThreadPool clientThreads = new QueuedThreadPool(); + clientThreads.setName("client"); + connector.setExecutor(clientThreads); + client = new HttpClient(new HttpClientTransportOverHTTP(connector)); + client.start(); + } + + @AfterEach + public void dispose() throws Exception + { + client.stop(); + proxy.close(); + } + + @Test + public void testSocks5ProxyIpv4NoAuth() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + CountDownLatch latch = new CountDownLatch(1); + + byte ip1 = 127; + byte ip2 = 0; + byte ip3 = 0; + byte ip4 = 13; + String serverHost = ip1 + "." + ip2 + "." + ip3 + "." + ip4; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(1, buffer.get()); + byte authenticationMethod = Socks5.NoAuthenticationFactory.METHOD; + assertEquals(authenticationMethod, buffer.get()); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_IPV4, buffer.get()); + assertEquals(ip1, buffer.get()); + assertEquals(ip2, buffer.get()); + assertEquals(ip3, buffer.get()); + assertEquals(ip4, buffer.get()); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 2, 13, 13 + })); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + @Test + public void testSocks5ProxyDomainNoAuth() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + CountDownLatch latch = new CountDownLatch(1); + + String serverHost = "example.com"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(1, buffer.get()); + byte authenticationMethod = Socks5.NoAuthenticationFactory.METHOD; + assertEquals(authenticationMethod, buffer.get()); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read server address. + int addrLen = 7 + serverHost.length(); + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_DOMAIN, buffer.get()); + int hostLen = buffer.get() & 0xFF; + assertEquals(serverHost.length(), hostLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + hostLen); + assertEquals(serverHost, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 3, 11, 11 + })); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + @Test + public void testSocks5ProxyIpv4UsernamePasswordAuth() throws Exception + { + String username = "jetty"; + String password = "pass"; + int proxyPort = proxy.socket().getLocalPort(); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); + + CountDownLatch latch = new CountDownLatch(1); + + byte ip1 = 127; + byte ip2 = 0; + byte ip3 = 0; + byte ip4 = 13; + String serverHost = ip1 + "." + ip2 + "." + ip3 + "." + ip4; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 2; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; + assertTrue(authTypeLen > 0); + + buffer = ByteBuffer.allocate(authTypeLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(authTypeLen, read); + byte[] authTypes = new byte[authTypeLen]; + buffer.get(authTypes); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read authentication request. + buffer = ByteBuffer.allocate(3 + username.length() + password.length()); + read = channel.read(buffer); + buffer.flip(); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); + + // Write authentication response. + channel.write(ByteBuffer.wrap(new byte[]{1, 0})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_IPV4, buffer.get()); + assertEquals(ip1, buffer.get()); + assertEquals(ip2, buffer.get()); + assertEquals(ip3, buffer.get()); + assertEquals(ip4, buffer.get()); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 4, 17, 17 + })); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + @Test + public void testSocks5ProxyAuthNoAcceptable() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + long timeout = 1000; + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + Request request = client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(timeout, TimeUnit.MILLISECONDS); + + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + assertEquals(initLen, read); + + // Deny authentication method. + byte notAcceptable = -1; + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, notAcceptable})); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(2 * timeout, TimeUnit.MILLISECONDS)); + assertThat(x.getCause(), instanceOf(IOException.class)); + } + } + + @Test + public void testSocks5ProxyUsernamePasswordAuthFailed() throws Exception + { + String username = "jetty"; + String password = "pass"; + int proxyPort = proxy.socket().getLocalPort(); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); + + long timeout = 1000; + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + Request request = client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(timeout, TimeUnit.MILLISECONDS); + + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 2; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; + assertTrue(authTypeLen > 0); + + buffer = ByteBuffer.allocate(authTypeLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(authTypeLen, read); + byte[] authTypes = new byte[authTypeLen]; + buffer.get(authTypes); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read authentication request. + buffer = ByteBuffer.allocate(3 + username.length() + password.length()); + read = channel.read(buffer); + buffer.flip(); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); + + // Fail authentication. + byte authenticationFailed = 1; // Any non-zero. + channel.write(ByteBuffer.wrap(new byte[]{1, authenticationFailed})); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(2 * timeout, TimeUnit.MILLISECONDS)); + assertThat(x.getCause(), instanceOf(IOException.class)); + } + } + + @Test + public void testSocks5ProxyDomainUsernamePasswordAuth() throws Exception + { + String username = "jetty"; + String password = "pass"; + int proxyPort = proxy.socket().getLocalPort(); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); + + CountDownLatch latch = new CountDownLatch(1); + + String serverHost = "example.com"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 2; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; + assertTrue(authTypeLen > 0); + + buffer = ByteBuffer.allocate(authTypeLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(authTypeLen, read); + byte[] authTypes = new byte[authTypeLen]; + buffer.get(authTypes); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read authentication request. + buffer = ByteBuffer.allocate(3 + username.length() + password.length()); + read = channel.read(buffer); + buffer.flip(); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); + + // Write authentication response. + channel.write(ByteBuffer.wrap(new byte[]{1, 0})); + + // Read server address. + int addrLen = 7 + serverHost.length(); + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_DOMAIN, buffer.get()); + int domainLen = buffer.get() & 0xFF; + assertEquals(serverHost.length(), domainLen); + limit = buffer.limit(); + buffer.limit(buffer.position() + domainLen); + assertEquals(serverHost, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 5, 19, 19 + })); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + @Test + public void testSocks5ProxyDomainUsernamePasswordAuthWithSplitResponse() throws Exception + { + String username = "jetty"; + String password = "pass"; + int proxyPort = proxy.socket().getLocalPort(); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); + + CountDownLatch latch = new CountDownLatch(1); + + String serverHost = "example.com"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 2; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; + assertTrue(authTypeLen > 0); + + buffer = ByteBuffer.allocate(authTypeLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(authTypeLen, read); + byte[] authTypes = new byte[authTypeLen]; + buffer.get(authTypes); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read authentication request. + buffer = ByteBuffer.allocate(3 + username.length() + password.length()); + read = channel.read(buffer); + buffer.flip(); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); + + // Write authentication response. + channel.write(ByteBuffer.wrap(new byte[]{1, 0})); + + // Read server address. + int addrLen = 7 + serverHost.length(); + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_DOMAIN, buffer.get()); + int domainLen = buffer.get() & 0xFF; + assertEquals(serverHost.length(), domainLen); + limit = buffer.limit(); + buffer.limit(buffer.position() + domainLen); + assertEquals(serverHost, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + byte[] chunk1 = new byte[]{Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4}; + channel.write(ByteBuffer.wrap(chunk1)); + // Wait before sending the second chunk. + Thread.sleep(1000); + byte[] chunk2 = new byte[]{127, 0, 0, 6, 21, 21}; + channel.write(ByteBuffer.wrap(chunk2)); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + @Test + public void testSocks5ProxyIpv4NoAuthWithTlsServer() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + String serverHost = "127.0.0.13"; // Server host different from proxy host. + int serverPort = proxyPort + 1; // Any port will do. + + SslContextFactory.Client clientTLS = client.getSslContextFactory(); + clientTLS.reload(ssl -> + { + // The client keystore contains the trustedCertEntry for the + // self-signed server certificate, so it acts as a truststore. + ssl.setTrustStorePath("src/test/resources/client_keystore.p12"); + ssl.setTrustStorePassword("storepwd"); + // Disable TLS hostname verification, but + // enable application hostname verification. + ssl.setEndpointIdentificationAlgorithm(null); + // The hostname must be that of the server, not of the proxy. + ssl.setHostnameVerifier((hostname, session) -> serverHost.equals(hostname)); + }); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + CountDownLatch latch = new CountDownLatch(1); + + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .scheme(HttpScheme.HTTPS.asString()) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, Socks5.NoAuthenticationFactory.METHOD})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + + // Write connect response. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 0, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 7, 23, 23 + })); + + // Wrap the socket with TLS. + SslContextFactory.Server serverTLS = new SslContextFactory.Server(); + serverTLS.setKeyStorePath("src/test/resources/keystore.p12"); + serverTLS.setKeyStorePassword("storepwd"); + serverTLS.start(); + SSLContext sslContext = serverTLS.getSslContext(); + SSLSocket sslSocket = (SSLSocket)sslContext.getSocketFactory().createSocket(channel.socket(), serverHost, serverPort, false); + sslSocket.setUseClientMode(false); + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(sslSocket.getInputStream()); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + OutputStream output = sslSocket.getOutputStream(); + output.write(response.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + @Test + public void testRequestTimeoutWhenSocksProxyDoesNotRespond() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks4Proxy("127.0.0.1", proxyPort)); + + long timeout = 1000; + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort) + .timeout(timeout, TimeUnit.MILLISECONDS); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel ignored = proxy.accept()) + { + // Accept the connection, but do not reply and don't close. + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(2 * timeout, TimeUnit.MILLISECONDS)); + assertThat(x.getCause(), instanceOf(TimeoutException.class)); + } + } + + @Test + public void testSocksProxyClosesConnectionInHandshake() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + // Immediately close the connection. + channel.close(); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(ClosedChannelException.class)); + } + } + + @Test + public void testSocksProxyClosesConnectionInAuthentication() throws Exception + { + String username = "jetty"; + String password = "pass"; + int proxyPort = proxy.socket().getLocalPort(); + Socks5Proxy socks5Proxy = new Socks5Proxy("127.0.0.1", proxyPort); + socks5Proxy.putAuthenticationFactory(new UsernamePasswordAuthenticationFactory(username, password)); + client.getProxyConfiguration().addProxy(socks5Proxy); + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 2; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + int authTypeLen = buffer.get() & 0xFF; + assertTrue(authTypeLen > 0); + + buffer = ByteBuffer.allocate(authTypeLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(authTypeLen, read); + byte[] authTypes = new byte[authTypeLen]; + buffer.get(authTypes); + byte authenticationMethod = Socks5.UsernamePasswordAuthenticationFactory.METHOD; + assertTrue(containsAuthType(authTypes, authenticationMethod)); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read authentication request. + buffer = ByteBuffer.allocate(3 + username.length() + password.length()); + read = channel.read(buffer); + buffer.flip(); + assertEquals(buffer.capacity(), read); + assertEquals(1, buffer.get()); + int usernameLen = buffer.get() & 0xFF; + assertEquals(username.length(), usernameLen); + int limit = buffer.limit(); + buffer.limit(buffer.position() + usernameLen); + assertEquals(username, StandardCharsets.US_ASCII.decode(buffer).toString()); + buffer.limit(limit); + int passwordLen = buffer.get() & 0xFF; + assertEquals(password.length(), passwordLen); + assertEquals(password, StandardCharsets.US_ASCII.decode(buffer).toString()); + + channel.close(); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(ClosedChannelException.class)); + } + } + + @Test + public void testSocksProxyClosesConnectionInConnect() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(1, buffer.get()); + byte authenticationMethod = Socks5.NoAuthenticationFactory.METHOD; + assertEquals(authenticationMethod, buffer.get()); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, authenticationMethod})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + + channel.close(); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(ClosedChannelException.class)); + } + } + + @Test + public void testSocksProxyResponseGarbageBytes() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + // Use an address to avoid resolution of "localhost" to multiple addresses. + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + channel.write(ByteBuffer.wrap(new byte[]{1, 2, 3, 4, 5})); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(IOException.class)); + } + } + + @Test + public void testSocks5ProxyConnectFailed() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + String serverHost = "127.0.0.13"; + int serverPort = proxyPort + 1; // Any port will do + Request request = client.newRequest(serverHost, serverPort); + FutureResponseListener listener = new FutureResponseListener(request); + request.send(listener); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, Socks5.NoAuthenticationFactory.METHOD})); + + // Read server address. + int addrLen = 10; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + + // Write connect response failure. + channel.write(ByteBuffer.wrap(new byte[]{ + Socks5.VERSION, 1, Socks5.RESERVED, Socks5.ADDRESS_TYPE_IPV4, 127, 0, 0, 8, 29, 29 + })); + + ExecutionException x = assertThrows(ExecutionException.class, () -> listener.get(5, TimeUnit.SECONDS)); + assertThat(x.getCause(), instanceOf(IOException.class)); + } + } + + @Test + public void testSocks5ProxyIPv6NoAuth() throws Exception + { + int proxyPort = proxy.socket().getLocalPort(); + client.getProxyConfiguration().addProxy(new Socks5Proxy("127.0.0.1", proxyPort)); + + CountDownLatch latch = new CountDownLatch(1); + + String serverHost = "::13"; + int serverPort = proxyPort + 1; // Any port will do + String method = "GET"; + String path = "/path"; + client.newRequest(serverHost, serverPort) + .method(method) + .path(path) + .timeout(5, TimeUnit.SECONDS) + .send(result -> + { + if (result.isSucceeded()) + latch.countDown(); + }); + + try (SocketChannel channel = proxy.accept()) + { + int initLen = 3; + ByteBuffer buffer = ByteBuffer.allocate(initLen); + int read = channel.read(buffer); + buffer.flip(); + assertEquals(initLen, read); + + // Write handshake response. + channel.write(ByteBuffer.wrap(new byte[]{Socks5.VERSION, Socks5.NoAuthenticationFactory.METHOD})); + + // Read server address. + int addrLen = 22; + buffer = ByteBuffer.allocate(addrLen); + read = channel.read(buffer); + buffer.flip(); + assertEquals(addrLen, read); + assertEquals(Socks5.VERSION, buffer.get()); + assertEquals(Socks5.COMMAND_CONNECT, buffer.get()); + assertEquals(Socks5.RESERVED, buffer.get()); + assertEquals(Socks5.ADDRESS_TYPE_IPV6, buffer.get()); + for (int i = 0; i < 15; ++i) + { + assertEquals(0, buffer.get()); + } + assertEquals(0x13, buffer.get()); + assertEquals(serverPort, buffer.getShort() & 0xFFFF); + + // Write connect response. + ByteBuffer byteBuffer = ByteBuffer.allocate(22) + .put(Socks5.VERSION) + .put((byte)0) + .put(Socks5.RESERVED) + .put(Socks5.ADDRESS_TYPE_IPV6) + .put(InetAddress.getByName(serverHost).getAddress()) + .putShort((short)3131) + .flip(); + // Write slowly 1 byte at a time. + for (int limit = 1; limit <= buffer.capacity(); ++limit) + { + byteBuffer.limit(limit); + channel.write(byteBuffer); + Thread.sleep(100); + } + + // Parse the HTTP request. + HttpTester.Request request = HttpTester.parseRequest(channel); + assertNotNull(request); + assertEquals(method, request.getMethod()); + assertEquals(path, request.getUri()); + + // Write the HTTP response. + String response = "HTTP/1.1 200 OK\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n"; + channel.write(ByteBuffer.wrap(response.getBytes(StandardCharsets.US_ASCII))); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + } + + private boolean containsAuthType(byte[] methods, byte method) + { + for (byte m : methods) + { + if (m == method) + return true; + } + return false; + } +} diff --git a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java index 9cc0c59242b..52af1cdc97b 100644 --- a/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java +++ b/jetty-http/src/main/java/org/eclipse/jetty/http/HttpTester.java @@ -176,6 +176,36 @@ public class HttpTester return r; } + public static Request parseRequest(InputStream inputStream) throws IOException + { + return parseRequest(from(inputStream)); + } + + public static Request parseRequest(ReadableByteChannel channel) throws IOException + { + return parseRequest(from(channel)); + } + + public static Request parseRequest(Input input) throws IOException + { + Request request; + HttpParser parser = input.takeHttpParser(); + if (parser != null) + { + request = (Request)parser.getHandler(); + } + else + { + request = newRequest(); + parser = new HttpParser(request); + } + parse(input, parser); + if (request.isComplete()) + return request; + input.setHttpParser(parser); + return null; + } + public static Response parseResponse(String response) { Response r = new Response(); @@ -230,7 +260,7 @@ public class HttpTester else r = (Response)parser.getHandler(); - parseResponse(in, parser, r); + parse(in, parser); if (r.isComplete()) return r; @@ -246,13 +276,13 @@ public class HttpTester { parser = new HttpParser(response); } - parseResponse(in, parser, response); + parse(in, parser); if (!response.isComplete()) in.setHttpParser(parser); } - private static void parseResponse(Input in, HttpParser parser, Response r) throws IOException + private static void parse(Input in, HttpParser parser) throws IOException { ByteBuffer buffer = in.getBuffer();