Skip to content

Commit b8d352b

Browse files
committed
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.
1 parent ac19392 commit b8d352b

File tree

3 files changed

+382
-0
lines changed

3 files changed

+382
-0
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.testing.async;
28+
29+
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
30+
import org.apache.hc.client5.http.config.ConnectionConfig;
31+
import org.apache.hc.client5.http.config.TlsConfig;
32+
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
33+
import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
34+
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
35+
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
36+
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
37+
import org.apache.hc.client5.testing.SSLTestContexts;
38+
import org.apache.hc.client5.testing.tls.TlsHandshakeTimeoutServer;
39+
import org.apache.hc.core5.reactor.IOReactorConfig;
40+
import org.apache.hc.core5.util.TimeValue;
41+
import org.junit.jupiter.api.Timeout;
42+
import org.junit.jupiter.params.ParameterizedTest;
43+
import org.junit.jupiter.params.provider.ValueSource;
44+
45+
import java.time.Duration;
46+
import java.time.temporal.ChronoUnit;
47+
import java.util.concurrent.ExecutionException;
48+
49+
import static java.lang.String.format;
50+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
51+
import static java.util.concurrent.TimeUnit.SECONDS;
52+
import static org.junit.jupiter.api.Assertions.assertThrows;
53+
import static org.junit.jupiter.api.Assertions.assertTrue;
54+
55+
public class TestAsyncTlsHandshakeTimeout {
56+
private static final Duration EXPECTED_TIMEOUT = Duration.ofMillis(500);
57+
58+
@Timeout(5)
59+
@ParameterizedTest
60+
@ValueSource(strings = { "false", "true" })
61+
void testTimeout(final boolean sendServerHello) throws Exception {
62+
final PoolingAsyncClientConnectionManager connMgr = PoolingAsyncClientConnectionManagerBuilder.create()
63+
.setDefaultConnectionConfig(ConnectionConfig.custom()
64+
.setConnectTimeout(5, SECONDS)
65+
.setSocketTimeout(5, SECONDS)
66+
.build())
67+
.setTlsStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext()))
68+
.setDefaultTlsConfig(TlsConfig.custom()
69+
.setHandshakeTimeout(EXPECTED_TIMEOUT.toMillis(), MILLISECONDS)
70+
.build())
71+
.build();
72+
try (
73+
final TlsHandshakeTimeoutServer server = new TlsHandshakeTimeoutServer(sendServerHello);
74+
final CloseableHttpAsyncClient client = HttpAsyncClientBuilder.create()
75+
.setIOReactorConfig(IOReactorConfig.custom()
76+
.setSelectInterval(TimeValue.ofMilliseconds(50))
77+
.build())
78+
.setConnectionManager(connMgr)
79+
.build()
80+
) {
81+
server.start();
82+
client.start();
83+
84+
final SimpleHttpRequest request = SimpleHttpRequest.create("GET", "https://127.0.0.1:" + server.getPort());
85+
assertTimeout(request, client);
86+
}
87+
}
88+
89+
private static void assertTimeout(final SimpleHttpRequest request, final CloseableHttpAsyncClient client) {
90+
final long startTime = System.nanoTime();
91+
final Throwable ex = assertThrows(ExecutionException.class,
92+
() -> client.execute(request, null).get()).getCause();
93+
final Duration actualTime = Duration.of(System.nanoTime() - startTime, ChronoUnit.NANOS);
94+
assertTrue(actualTime.toMillis() > EXPECTED_TIMEOUT.toMillis() / 2,
95+
format("Handshake attempt timed out too soon (only %,d out of %,d ms)",
96+
actualTime.toMillis(),
97+
EXPECTED_TIMEOUT.toMillis()));
98+
assertTrue(actualTime.toMillis() < EXPECTED_TIMEOUT.toMillis() * 2,
99+
format("Handshake attempt timed out too late (%,d out of %,d ms)",
100+
actualTime.toMillis(),
101+
EXPECTED_TIMEOUT.toMillis()));
102+
assertTrue(ex.getMessage().contains(EXPECTED_TIMEOUT.toMillis() + " MILLISECONDS"), ex.getMessage());
103+
}
104+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.testing.sync;
28+
29+
import org.apache.hc.client5.http.ConnectTimeoutException;
30+
import org.apache.hc.client5.http.classic.HttpClient;
31+
import org.apache.hc.client5.http.classic.methods.HttpGet;
32+
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
33+
import org.apache.hc.client5.http.config.ConnectionConfig;
34+
import org.apache.hc.client5.http.config.TlsConfig;
35+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
36+
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
37+
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
38+
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
39+
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
40+
import org.apache.hc.client5.testing.SSLTestContexts;
41+
import org.apache.hc.client5.testing.tls.TlsHandshakeTimeoutServer;
42+
import org.apache.hc.core5.http.ClassicHttpRequest;
43+
import org.junit.jupiter.api.Timeout;
44+
import org.junit.jupiter.params.ParameterizedTest;
45+
import org.junit.jupiter.params.provider.ValueSource;
46+
47+
import javax.net.ssl.SSLException;
48+
import java.time.Duration;
49+
import java.time.temporal.ChronoUnit;
50+
51+
import static java.lang.String.format;
52+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
53+
import static java.util.concurrent.TimeUnit.SECONDS;
54+
import static org.apache.hc.core5.util.ReflectionUtils.determineJRELevel;
55+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
56+
import static org.junit.jupiter.api.Assertions.assertThrows;
57+
import static org.junit.jupiter.api.Assertions.assertTrue;
58+
import static org.junit.jupiter.api.Assumptions.assumeFalse;
59+
60+
public class TestTlsHandshakeTimeout {
61+
private static final Duration EXPECTED_TIMEOUT = Duration.ofMillis(500);
62+
63+
@Timeout(5)
64+
@ParameterizedTest
65+
@ValueSource(strings = { "false", "true" })
66+
void testTimeout(final boolean sendServerHello) throws Exception {
67+
final PoolingHttpClientConnectionManager connMgr = PoolingHttpClientConnectionManagerBuilder.create()
68+
.setDefaultConnectionConfig(ConnectionConfig.custom()
69+
.setConnectTimeout(5, SECONDS)
70+
.setSocketTimeout(5, SECONDS)
71+
.build())
72+
.setTlsSocketStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext()))
73+
.setDefaultTlsConfig(TlsConfig.custom()
74+
.setHandshakeTimeout(EXPECTED_TIMEOUT.toMillis(), MILLISECONDS)
75+
.build())
76+
.build();
77+
try (
78+
final TlsHandshakeTimeoutServer server = new TlsHandshakeTimeoutServer(sendServerHello);
79+
final CloseableHttpClient client = HttpClientBuilder.create()
80+
.setConnectionManager(connMgr)
81+
.build()
82+
) {
83+
server.start();
84+
85+
final HttpUriRequestBase request = new HttpGet("https://127.0.0.1:" + server.getPort());
86+
assertTimeout(request, client);
87+
}
88+
}
89+
90+
@SuppressWarnings("deprecation")
91+
private static void assertTimeout(final ClassicHttpRequest request, final HttpClient client) {
92+
final long startTime = System.nanoTime();
93+
final Exception ex = assertThrows(Exception.class, () -> client.execute(request));
94+
final Duration actualTime = Duration.of(System.nanoTime() - startTime, ChronoUnit.NANOS);
95+
96+
if (determineJRELevel() == 8) {
97+
assertInstanceOf(SSLException.class, ex);
98+
} else {
99+
assertInstanceOf(ConnectTimeoutException.class, ex);
100+
}
101+
assertTrue(ex.getMessage().contains("Read timed out"), ex.getMessage());
102+
103+
// There is a bug in Java 11: after the handshake times out, the SSLSocket implementation performs a blocking
104+
// read on the socket to wait for close_notify or alert. This operation blocks until the read times out,
105+
// which means that TLS handshakes take twice as long to time out on Java 11. Without a workaround, the only
106+
// option is to skip the timeout duration assertions on Java 11.
107+
assumeFalse(determineJRELevel() == 11, "TLS handshake timeouts are buggy on Java 11");
108+
109+
assertTrue(actualTime.toMillis() > EXPECTED_TIMEOUT.toMillis() / 2,
110+
format("Handshake attempt timed out too soon (only %,d out of %,d ms)",
111+
actualTime.toMillis(),
112+
EXPECTED_TIMEOUT.toMillis()));
113+
assertTrue(actualTime.toMillis() < EXPECTED_TIMEOUT.toMillis() * 2,
114+
format("Handshake attempt timed out too late (%,d out of %,d ms)",
115+
actualTime.toMillis(),
116+
EXPECTED_TIMEOUT.toMillis()));
117+
}
118+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.testing.tls;
28+
29+
import org.apache.hc.client5.testing.SSLTestContexts;
30+
31+
import javax.net.ssl.SSLContext;
32+
import javax.net.ssl.SSLEngine;
33+
import javax.net.ssl.SSLEngineResult.HandshakeStatus;
34+
import javax.net.ssl.SSLEngineResult.Status;
35+
import javax.net.ssl.SSLSession;
36+
import java.io.Closeable;
37+
import java.io.IOException;
38+
import java.net.InetSocketAddress;
39+
import java.nio.ByteBuffer;
40+
import java.nio.channels.ServerSocketChannel;
41+
import java.nio.channels.SocketChannel;
42+
43+
/**
44+
* This test server accepts a single TLS connection request, which will then time out. The server can run in two modes.
45+
* If {@code sendServerHello} is false, the Client Hello message will be swallowed, and the client will time out while
46+
* waiting for the Server Hello record. Else, the server will respond to the Client Hello with a Server Hello, and the
47+
* client's connection attempt will subsequently time out while waiting for the Change Cipher Spec record from the
48+
* server.
49+
*/
50+
public class TlsHandshakeTimeoutServer implements Closeable {
51+
private final boolean sendServerHello;
52+
53+
private volatile int port = -1;
54+
private volatile boolean requestReceived = false;
55+
private volatile ServerSocketChannel serverSocket;
56+
private volatile SocketChannel socket;
57+
private volatile Throwable throwable;
58+
59+
public TlsHandshakeTimeoutServer(final boolean sendServerHello) {
60+
this.sendServerHello = sendServerHello;
61+
}
62+
63+
public void start() throws IOException {
64+
this.serverSocket = ServerSocketChannel.open();
65+
this.serverSocket.bind(new InetSocketAddress("0.0.0.0", 0));
66+
this.port = ((InetSocketAddress) this.serverSocket.getLocalAddress()).getPort();
67+
new Thread(this::run).start();
68+
}
69+
70+
private void run() {
71+
try {
72+
socket = serverSocket.accept();
73+
requestReceived = true;
74+
75+
if (sendServerHello) {
76+
final SSLEngine sslEngine = initHandshake();
77+
78+
receiveClientHello(sslEngine);
79+
sendServerHello(sslEngine);
80+
}
81+
} catch (final Throwable t) {
82+
this.throwable = t;
83+
}
84+
}
85+
86+
private SSLEngine initHandshake() throws Exception {
87+
final SSLContext sslContext = SSLTestContexts.createServerSSLContext();
88+
final SSLEngine sslEngine = sslContext.createSSLEngine();
89+
// TLSv1.2 always uses a four-way handshake, which is what we want
90+
sslEngine.setEnabledProtocols(new String[]{ "TLSv1.2" });
91+
sslEngine.setUseClientMode(false);
92+
sslEngine.setNeedClientAuth(false);
93+
94+
sslEngine.beginHandshake();
95+
return sslEngine;
96+
}
97+
98+
private void receiveClientHello(final SSLEngine sslEngine) throws IOException {
99+
final SSLSession session = sslEngine.getSession();
100+
final ByteBuffer clientNetData = ByteBuffer.allocate(session.getPacketBufferSize());
101+
final ByteBuffer clientAppData = ByteBuffer.allocate(session.getApplicationBufferSize());
102+
while (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_UNWRAP) {
103+
socket.read(clientNetData);
104+
clientNetData.flip();
105+
final Status status = sslEngine.unwrap(clientNetData, clientAppData).getStatus();
106+
if (status != Status.OK) {
107+
throw new RuntimeException("Bad status while unwrapping data: " + status);
108+
}
109+
clientNetData.compact();
110+
if (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_TASK) {
111+
sslEngine.getDelegatedTask().run();
112+
}
113+
}
114+
}
115+
116+
private void sendServerHello(final SSLEngine sslEngine) throws IOException {
117+
final SSLSession session = sslEngine.getSession();
118+
final ByteBuffer serverAppData = ByteBuffer.allocate(session.getApplicationBufferSize());
119+
final ByteBuffer serverNetData = ByteBuffer.allocate(session.getPacketBufferSize());
120+
while (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_WRAP) {
121+
serverAppData.flip();
122+
final Status status = sslEngine.wrap(serverAppData, serverNetData).getStatus();
123+
if (status != Status.OK) {
124+
throw new RuntimeException("Bad status while wrapping data: " + status);
125+
}
126+
serverNetData.flip();
127+
socket.write(serverNetData);
128+
serverNetData.compact();
129+
if (sslEngine.getHandshakeStatus() == HandshakeStatus.NEED_TASK) {
130+
sslEngine.getDelegatedTask().run();
131+
}
132+
}
133+
}
134+
135+
public int getPort() {
136+
if (port == -1) {
137+
throw new IllegalStateException("Server has not been started yet");
138+
}
139+
return port;
140+
}
141+
142+
@Override
143+
public void close() {
144+
try {
145+
if (serverSocket != null) {
146+
serverSocket.close();
147+
}
148+
if (socket != null) {
149+
socket.close();
150+
}
151+
} catch (final IOException ignore) {
152+
}
153+
154+
if (throwable != null) {
155+
throw new RuntimeException("Exception thrown while TlsHandshakeTimerOuter was running", throwable);
156+
} else if (!requestReceived) {
157+
throw new IllegalStateException("Never received a request");
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)