Add integration tests for TLS handshake timeouts
This change adds basic integration test coverage for TLS handshake
timeouts for the sync and async clients. The tests make use of a special
test server that times out a single TLS connection attempt and can be
configured to time out at two different points in the TLS 4-way
handshake.
Note that the TLS handshake timeout, as currently implemented, works
like a socket timeout for the TLS handshake phase of the connection: it
only limits the amount of time that will be spent on each individual
socket read/write operations, not the total time spent in the handshake
attempt. The timeout server, for example, could inject a delay before
sending the Server Hello, which would cause the client to spend up to
double the configured timeout attempting to complete the handshake.
There is no test coverage for this behavior, but it could be added if we
decided that it should be part of the feature's contract.
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 0000000..2a09af5
--- /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
+ * <http://www.apache.org/>.
+ *
+ */
+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 0000000..9fde43e
--- /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
+ * <http://www.apache.org/>.
+ *
+ */
+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 0000000..d77959d
--- /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
+ * <http://www.apache.org/>.
+ *
+ */
+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");
+ }
+ }
+}