diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsHandshakeTimeout.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsHandshakeTimeout.java new file mode 100644 index 0000000000..2a09af5e19 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsHandshakeTimeout.java @@ -0,0 +1,104 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.async; + +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.testing.SSLTestContexts; +import org.apache.hc.client5.testing.tls.TlsHandshakeTimeoutServer; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.util.TimeValue; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.ExecutionException; + +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TestAsyncTlsHandshakeTimeout { + private static final Duration EXPECTED_TIMEOUT = Duration.ofMillis(500); + + @Timeout(5) + @ParameterizedTest + @ValueSource(strings = { "false", "true" }) + void testTimeout(final boolean sendServerHello) throws Exception { + final PoolingAsyncClientConnectionManager connMgr = PoolingAsyncClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(5, SECONDS) + .setSocketTimeout(5, SECONDS) + .build()) + .setTlsStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext())) + .setDefaultTlsConfig(TlsConfig.custom() + .setHandshakeTimeout(EXPECTED_TIMEOUT.toMillis(), MILLISECONDS) + .build()) + .build(); + try ( + final TlsHandshakeTimeoutServer server = new TlsHandshakeTimeoutServer(sendServerHello); + final CloseableHttpAsyncClient client = HttpAsyncClientBuilder.create() + .setIOReactorConfig(IOReactorConfig.custom() + .setSelectInterval(TimeValue.ofMilliseconds(50)) + .build()) + .setConnectionManager(connMgr) + .build() + ) { + server.start(); + client.start(); + + final SimpleHttpRequest request = SimpleHttpRequest.create("GET", "https://127.0.0.1:" + server.getPort()); + assertTimeout(request, client); + } + } + + private static void assertTimeout(final SimpleHttpRequest request, final CloseableHttpAsyncClient client) { + final long startTime = System.nanoTime(); + final Throwable ex = assertThrows(ExecutionException.class, + () -> client.execute(request, null).get()).getCause(); + final Duration actualTime = Duration.of(System.nanoTime() - startTime, ChronoUnit.NANOS); + assertTrue(actualTime.toMillis() > EXPECTED_TIMEOUT.toMillis() / 2, + format("Handshake attempt timed out too soon (only %,d out of %,d ms)", + actualTime.toMillis(), + EXPECTED_TIMEOUT.toMillis())); + assertTrue(actualTime.toMillis() < EXPECTED_TIMEOUT.toMillis() * 2, + format("Handshake attempt timed out too late (%,d out of %,d ms)", + actualTime.toMillis(), + EXPECTED_TIMEOUT.toMillis())); + assertTrue(ex.getMessage().contains(EXPECTED_TIMEOUT.toMillis() + " MILLISECONDS"), ex.getMessage()); + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestTlsHandshakeTimeout.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestTlsHandshakeTimeout.java new file mode 100644 index 0000000000..9fde43e11b --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/sync/TestTlsHandshakeTimeout.java @@ -0,0 +1,118 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.sync; + +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.testing.SSLTestContexts; +import org.apache.hc.client5.testing.tls.TlsHandshakeTimeoutServer; +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.net.ssl.SSLException; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +import static java.lang.String.format; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.hc.core5.util.ReflectionUtils.determineJRELevel; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +public class TestTlsHandshakeTimeout { + private static final Duration EXPECTED_TIMEOUT = Duration.ofMillis(500); + + @Timeout(5) + @ParameterizedTest + @ValueSource(strings = { "false", "true" }) + void testTimeout(final boolean sendServerHello) throws Exception { + final PoolingHttpClientConnectionManager connMgr = PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(5, SECONDS) + .setSocketTimeout(5, SECONDS) + .build()) + .setTlsSocketStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext())) + .setDefaultTlsConfig(TlsConfig.custom() + .setHandshakeTimeout(EXPECTED_TIMEOUT.toMillis(), MILLISECONDS) + .build()) + .build(); + try ( + final TlsHandshakeTimeoutServer server = new TlsHandshakeTimeoutServer(sendServerHello); + final CloseableHttpClient client = HttpClientBuilder.create() + .setConnectionManager(connMgr) + .build() + ) { + server.start(); + + final HttpUriRequestBase request = new HttpGet("https://127.0.0.1:" + server.getPort()); + assertTimeout(request, client); + } + } + + @SuppressWarnings("deprecation") + private static void assertTimeout(final ClassicHttpRequest request, final HttpClient client) { + final long startTime = System.nanoTime(); + final Exception ex = assertThrows(Exception.class, () -> client.execute(request)); + final Duration actualTime = Duration.of(System.nanoTime() - startTime, ChronoUnit.NANOS); + + if (determineJRELevel() == 8) { + assertInstanceOf(SSLException.class, ex); + } else { + assertInstanceOf(ConnectTimeoutException.class, ex); + } + assertTrue(ex.getMessage().contains("Read timed out"), ex.getMessage()); + + // There is a bug in Java 11: after the handshake times out, the SSLSocket implementation performs a blocking + // read on the socket to wait for close_notify or alert. This operation blocks until the read times out, + // which means that TLS handshakes take twice as long to time out on Java 11. Without a workaround, the only + // option is to skip the timeout duration assertions on Java 11. + assumeFalse(determineJRELevel() == 11, "TLS handshake timeouts are buggy on Java 11"); + + assertTrue(actualTime.toMillis() > EXPECTED_TIMEOUT.toMillis() / 2, + format("Handshake attempt timed out too soon (only %,d out of %,d ms)", + actualTime.toMillis(), + EXPECTED_TIMEOUT.toMillis())); + assertTrue(actualTime.toMillis() < EXPECTED_TIMEOUT.toMillis() * 2, + format("Handshake attempt timed out too late (%,d out of %,d ms)", + actualTime.toMillis(), + EXPECTED_TIMEOUT.toMillis())); + } +} diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/tls/TlsHandshakeTimeoutServer.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/tls/TlsHandshakeTimeoutServer.java new file mode 100644 index 0000000000..d77959de81 --- /dev/null +++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/tls/TlsHandshakeTimeoutServer.java @@ -0,0 +1,160 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.testing.tls; + +import org.apache.hc.client5.testing.SSLTestContexts; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLEngineResult.HandshakeStatus; +import javax.net.ssl.SSLEngineResult.Status; +import javax.net.ssl.SSLSession; +import java.io.Closeable; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; + +/** + * This test server accepts a single TLS connection request, which will then time out. The server can run in two modes. + * If {@code sendServerHello} is false, the Client Hello message will be swallowed, and the client will time out while + * waiting for the Server Hello record. Else, the server will respond to the Client Hello with a Server Hello, and the + * client's connection attempt will subsequently time out while waiting for the Change Cipher Spec record from the + * server. + */ +public class TlsHandshakeTimeoutServer implements Closeable { + private final boolean sendServerHello; + + private volatile int port = -1; + private volatile boolean requestReceived = false; + private volatile ServerSocketChannel serverSocket; + private volatile SocketChannel socket; + private volatile Throwable throwable; + + public TlsHandshakeTimeoutServer(final boolean sendServerHello) { + this.sendServerHello = sendServerHello; + } + + public void start() throws IOException { + this.serverSocket = ServerSocketChannel.open(); + this.serverSocket.bind(new InetSocketAddress("0.0.0.0", 0)); + this.port = ((InetSocketAddress) this.serverSocket.getLocalAddress()).getPort(); + new Thread(this::run).start(); + } + + private void run() { + try { + socket = serverSocket.accept(); + requestReceived = true; + + if (sendServerHello) { + final SSLEngine sslEngine = initHandshake(); + + receiveClientHello(sslEngine); + sendServerHello(sslEngine); + } + } catch (final Throwable t) { + this.throwable = t; + } + } + + private SSLEngine initHandshake() throws Exception { + final SSLContext sslContext = SSLTestContexts.createServerSSLContext(); + final SSLEngine sslEngine = sslContext.createSSLEngine(); + // TLSv1.2 always uses a four-way handshake, which is what we want + sslEngine.setEnabledProtocols(new String[]{ "TLSv1.2" }); + sslEngine.setUseClientMode(false); + sslEngine.setNeedClientAuth(false); + + sslEngine.beginHandshake(); + return sslEngine; + } + + private void receiveClientHello(final SSLEngine sslEngine) throws IOException { + final SSLSession session = sslEngine.getSession(); + final ByteBuffer clientNetData = ByteBuffer.allocate(session.getPacketBufferSize()); + final ByteBuffer clientAppData = ByteBuffer.allocate(session.getApplicationBufferSize()); + while (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_UNWRAP) { + socket.read(clientNetData); + clientNetData.flip(); + final Status status = sslEngine.unwrap(clientNetData, clientAppData).getStatus(); + if (status != Status.OK) { + throw new RuntimeException("Bad status while unwrapping data: " + status); + } + clientNetData.compact(); + if (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_TASK) { + sslEngine.getDelegatedTask().run(); + } + } + } + + private void sendServerHello(final SSLEngine sslEngine) throws IOException { + final SSLSession session = sslEngine.getSession(); + final ByteBuffer serverAppData = ByteBuffer.allocate(session.getApplicationBufferSize()); + final ByteBuffer serverNetData = ByteBuffer.allocate(session.getPacketBufferSize()); + while (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_WRAP) { + serverAppData.flip(); + final Status status = sslEngine.wrap(serverAppData, serverNetData).getStatus(); + if (status != Status.OK) { + throw new RuntimeException("Bad status while wrapping data: " + status); + } + serverNetData.flip(); + socket.write(serverNetData); + serverNetData.compact(); + if (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_TASK) { + sslEngine.getDelegatedTask().run(); + } + } + } + + public int getPort() { + if (port == -1) { + throw new IllegalStateException("Server has not been started yet"); + } + return port; + } + + @Override + public void close() { + try { + if (serverSocket != null) { + serverSocket.close(); + } + if (socket != null) { + socket.close(); + } + } catch (final IOException ignore) { + } + + if (throwable != null) { + throw new RuntimeException("Exception thrown while TlsHandshakeTimerOuter was running", throwable); + } else if (!requestReceived) { + throw new IllegalStateException("Never received a request"); + } + } +}