Skip to content

Commit 9d4315e

Browse files
Bound the TLS handshake with connectTimeoutMs (#25)
After the TCP connect the socket is blocking, and bio_recv called recv without waiting unless a stop token was attached, so a server or an https:// proxy that accepted the connection and never sent its ServerHello held send(), send_stream() and download_to_file() for good. setup_tls sets a deadline of connectTimeoutMs on the socket at the bottom for the duration of the handshake; bio_recv waits against it (and against the token, if any), and a handshake that runs out of time fails with "TLS handshake timed out". It covers the target, an https:// proxy, and the target inside an https:// proxy's tunnel. A wait that a signal ends early is waited again; the deadline is hit only when the clock has passed it. TlsSocket::connect_over takes a trailing handshakeTimeoutMs (-1, the default, waits without limit); TlsSocket::connect bounds the handshake with the timeout it already took. Socket gains set_deadline, deadline_hit and wait_before_recv. CI: the openkal job on Windows finds Git for Windows's CA bundle instead of naming mingw64/ (Git 2.56 moved it under ucrt64/), and the Windows job runs test_handshake_timeout. Co-authored-by: Sunrisepeak <speakshen@163.com>
1 parent 2cc7310 commit 9d4315e

7 files changed

Lines changed: 420 additions & 24 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,26 @@ jobs:
288288
# "Platforms"), so the program is given a bundle: Git for Windows's, copied
289289
# beside it, because openkal resolves a relative name against the working
290290
# directory and names no absolute path.
291+
#
292+
# Git is asked where its bundle is, and the install searched after that,
293+
# rather than the directory named: the runner image that brought Git for
294+
# Windows 2.56 has no `mingw64/etc/ssl/certs/ca-bundle.crt`, and the job
295+
# failed there before the program ran.
291296
- name: Run above openkal
292297
run: |
293298
set -o pipefail
294-
cp "/c/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt" ca-bundle.crt
299+
git --version
300+
ca=$(git config --system --get http.sslcainfo || true)
301+
if [ -n "$ca" ]; then ca=$(cygpath -u "$ca"); fi
302+
if [ ! -f "$ca" ]; then
303+
ca=$(find "/c/Program Files/Git" -path '*/ssl/certs/ca-bundle.crt' -print -quit)
304+
fi
305+
if [ ! -f "$ca" ]; then
306+
echo "::error::no CA bundle in the Git for Windows install"
307+
exit 1
308+
fi
309+
echo "CA bundle: $ca"
310+
cp "$ca" ca-bundle.crt
295311
SSL_CERT_FILE=ca-bundle.crt ./smoke.exe 2>&1 | tee run.log
296312
grep -q '^cancellation: ok' run.log
297313
@@ -352,7 +368,7 @@ jobs:
352368
- name: The hermetic tests
353369
run: |
354370
set -o pipefail
355-
for t in test_framing test_tls_verify test_pool test_proxy test_cancel; do
371+
for t in test_framing test_tls_verify test_pool test_proxy test_cancel test_handshake_timeout; do
356372
mcpp test "$t" 2>&1 | tee "$t.log"
357373
grep -q "^$t \.\.\. ok" "$t.log"
358374
done

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
The TLS handshake is bounded by `connectTimeoutMs`. It had no limit: after the
6+
TCP connection was up, a server (or an https:// proxy) that accepted it and never
7+
sent its ServerHello held `send`, `send_stream` and `download_to_file` for good
8+
unless the caller passed a stop token.
9+
10+
* The handshake gives up once `connectTimeoutMs` has passed since it began, and
11+
`statusText` is `TLS handshake timed out`. This covers the handshake with the
12+
target, with an https:// proxy, and with the target inside an https:// proxy's
13+
tunnel. The limit is on the handshake as a whole, so a peer that sends a byte
14+
at a time does not hold it open.
15+
* Behaviour change: a handshake slower than `connectTimeoutMs` (10 s by default)
16+
now fails where it used to complete. Each step of setting up a connection (the
17+
TCP connect, a proxy's reply, a handshake) has `connectTimeoutMs` to itself, so
18+
the whole can take several times that.
19+
* `TlsSocket::connect` bounds the handshake with the `timeoutMs` it already
20+
took for the TCP connect, so a caller of it directly sees the change too.
21+
* `TlsSocket::connect_over` takes a trailing `handshakeTimeoutMs`, `-1` (the
22+
default) for no limit; a call by name compiles unchanged, a pointer to it needs
23+
the new parameter in its type. `Socket` gains `set_deadline`, `deadline_hit`
24+
and `wait_before_recv`.
25+
326
## 0.3.4
427

528
A request in flight can be abandoned from another thread. Everything is added

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ includes in that file, or use libc++ 23 or libstdc++.
7979

8080
| field | default | what it decides |
8181
| --- | --- | --- |
82-
| `connectTimeoutMs` | 10000 | TCP connect |
82+
| `connectTimeoutMs` | 10000 | setting up the connection: the TCP connect, each wait for a proxy's reply, and the TLS handshake, with the proxy and with the target (the handshake as a whole, not each read in it) |
8383
| `readTimeoutMs` | 60000 | any single read, and the total wait on a blocked write |
8484
| `verifySsl` | true | verify the server certificate against the CA bundle (`SSL_CERT_FILE`, else the Windows `ROOT` certificate store in Windows Sockets builds, else a system location); the connection fails if the certificate is not trusted, has expired or is for another host, or if no bundle is found |
8585
| `keepAlive` | true | reuse connections between requests |

‎src/http.cppm‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,10 +1052,10 @@ private:
10521052
}
10531053
if (tunnel.proxyTls) {
10541054
return sock.connect_over(std::move(tunnel.proxyTls), parsed.host.c_str(),
1055-
config_.verifySsl);
1055+
config_.verifySsl, config_.connectTimeoutMs);
10561056
}
10571057
return sock.connect_over(std::move(tunnel.socket), parsed.host.c_str(),
1058-
config_.verifySsl);
1058+
config_.verifySsl, config_.connectTimeoutMs);
10591059
}
10601060
return sock.connect(parsed.host.c_str(), parsed.port,
10611061
config_.connectTimeoutMs, config_.verifySsl);

‎src/socket.cppm‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public:
5252

5353
// Move constructor
5454
Socket(Socket&& other) noexcept
55-
: fd_(other.fd_), stop_(std::move(other.stop_)) {
55+
: fd_(other.fd_), stop_(std::move(other.stop_)), deadline_(other.deadline_)
56+
, deadline_hit_(other.deadline_hit_) {
5657
other.fd_ = INVALID_SOCKET_FD;
5758
}
5859

@@ -62,6 +63,8 @@ public:
6263
close();
6364
fd_ = other.fd_;
6465
stop_ = std::move(other.stop_);
66+
deadline_ = other.deadline_;
67+
deadline_hit_ = other.deadline_hit_;
6568
other.fd_ = INVALID_SOCKET_FD;
6669
}
6770
return *this;
@@ -77,6 +80,42 @@ public:
7780

7881
[[nodiscard]] bool stop_possible() const { return stop_.stop_possible(); }
7982

83+
// Bounds the TLS handshake: until it is cleared, `wait_before_recv` gives up
84+
// once this time has passed. Cleared once the handshake is over.
85+
void set_deadline(std::optional<std::chrono::steady_clock::time_point> deadline) {
86+
deadline_ = deadline;
87+
deadline_hit_ = false;
88+
}
89+
90+
// True once `wait_before_recv` gave up because of the deadline.
91+
[[nodiscard]] bool deadline_hit() const { return deadline_hit_; }
92+
93+
// What `bio_recv` calls ahead of a `recv` that would otherwise block. True
94+
// when a `recv` now will not block. With neither a token nor a deadline it
95+
// answers true without waiting, so the plain `recv` is as it was.
96+
//
97+
// A wait that ends before the deadline is waited again for what is left: a
98+
// signal ends `poll` with EINTR even under SA_RESTART, which the blocking
99+
// `recv` this wait stands in front of would have restarted. The deadline is
100+
// hit only when the clock says so.
101+
bool wait_before_recv() {
102+
if (!stop_.stop_possible() && !deadline_) return true;
103+
for (;;) {
104+
int wait = -1;
105+
if (deadline_) {
106+
const auto left = std::chrono::ceil<std::chrono::milliseconds>(
107+
*deadline_ - std::chrono::steady_clock::now()).count();
108+
wait = left > 0 ? static_cast<int>(left) : 0;
109+
}
110+
if (wait_readable(wait)) return true;
111+
if (stop_.stop_requested() || !deadline_) return false;
112+
if (std::chrono::steady_clock::now() >= *deadline_) {
113+
deadline_hit_ = true;
114+
return false;
115+
}
116+
}
117+
}
118+
80119
bool connect(const char* host, int port, int timeoutMs) {
81120
// Close existing connection if any
82121
if (is_valid()) {
@@ -342,6 +381,8 @@ public:
342381
private:
343382
SocketHandle fd_ = INVALID_SOCKET_FD;
344383
std::stop_token stop_;
384+
std::optional<std::chrono::steady_clock::time_point> deadline_;
385+
bool deadline_hit_ = false;
345386

346387
// poll_fd in slices while a token is attached. A negative timeout waits
347388
// without limit. Not std::min: <winsock2.h> defines a `min` macro.

‎src/tls.cppm‎

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ static int bio_send(void* ctx, const unsigned char* buf, size_t len) {
9494
// complete and correct.
9595
static int bio_recv(void* ctx, unsigned char* buf, size_t len) {
9696
auto* sock = static_cast<Socket*>(ctx);
97-
// With a stop token, wait here so the recv below cannot block past a stop.
98-
if (sock->stop_possible() && !sock->wait_readable(-1)) {
97+
// With a stop token or a handshake deadline, wait here so the recv below
98+
// cannot block past a stop or the deadline.
99+
if (!sock->wait_before_recv()) {
99100
return MBEDTLS_ERR_NET_RECV_FAILED;
100101
}
101102
int ret = sock->read(reinterpret_cast<char*>(buf), static_cast<int>(len));
@@ -208,20 +209,24 @@ public:
208209

209210
// Connect over an already-established Socket (e.g. a proxy tunnel).
210211
// Takes ownership of the socket and performs TLS handshake on top of it.
211-
bool connect_over(Socket&& socket, const char* host, bool verifySsl) {
212+
// The handshake gives up after `handshakeTimeoutMs`; a negative value waits
213+
// without limit.
214+
bool connect_over(Socket&& socket, const char* host, bool verifySsl,
215+
int handshakeTimeoutMs = -1) {
212216
error_.clear();
213217
socket_ = std::move(socket);
214-
return setup_tls(host, verifySsl);
218+
return setup_tls(host, verifySsl, handshakeTimeoutMs);
215219
}
216220

217221
// Run the handshake inside another TLS session, which is how a client
218222
// reaches a target through an https:// proxy: TLS to the proxy, CONNECT
219223
// inside it, then this session to the target inside the tunnel. Takes
220224
// ownership of `lower`, which must already be past its CONNECT.
221-
bool connect_over(std::unique_ptr<TlsSocket> lower, const char* host, bool verifySsl) {
225+
bool connect_over(std::unique_ptr<TlsSocket> lower, const char* host, bool verifySsl,
226+
int handshakeTimeoutMs = -1) {
222227
error_.clear();
223228
lower_ = std::move(lower);
224-
return setup_tls(host, verifySsl);
229+
return setup_tls(host, verifySsl, handshakeTimeoutMs);
225230
}
226231

227232
bool connect(const char* host, int port, int timeoutMs, bool verifySsl) {
@@ -231,7 +236,7 @@ public:
231236
return false;
232237
}
233238

234-
return setup_tls(host, verifySsl);
239+
return setup_tls(host, verifySsl, timeoutMs);
235240
}
236241

237242
// The read that says which of the four things happened. Prefer it over
@@ -371,7 +376,16 @@ private:
371376
socket_.close();
372377
}
373378

374-
bool setup_tls(const char* host, bool verifySsl) {
379+
// As `set_stop`, the deadline belongs to the socket at the bottom, which
380+
// inside a tunnel is the one beneath `lower_`.
381+
void set_deadline(std::optional<std::chrono::steady_clock::time_point> deadline) {
382+
if (lower_) lower_->set_deadline(deadline);
383+
else socket_.set_deadline(deadline);
384+
}
385+
386+
bool deadline_hit() const { return lower_ ? lower_->deadline_hit() : socket_.deadline_hit(); }
387+
388+
bool setup_tls(const char* host, bool verifySsl, int handshakeTimeoutMs) {
375389
state_ = std::make_unique<TlsState>();
376390

377391
int ret = mbedtls_ctr_drbg_seed(
@@ -429,17 +443,30 @@ private:
429443
// Set BIO callbacks using our Socket, or the session beneath this one
430444
bind_bio();
431445

432-
// Perform TLS handshake
433-
while ((ret = mbedtls_ssl_handshake(&state_->ssl)) != 0) {
434-
if (ret != MBEDTLS_ERR_SSL_WANT_READ && ret != MBEDTLS_ERR_SSL_WANT_WRITE) {
435-
if (ret == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) {
436-
char info[512] = {};
437-
mbedtls_x509_crt_verify_info(info, sizeof info, "",
438-
mbedtls_ssl_get_verify_result(&state_->ssl));
439-
return fail("certificate verification failed: " + one_line(info));
440-
}
441-
return fail("TLS handshake failed: " + mbedtls_message(ret));
446+
// Perform TLS handshake. The socket is blocking, so `bio_recv` waits
447+
// against the deadline before it reads, and the deadline is taken off
448+
// again before the connection is used.
449+
std::optional<std::chrono::steady_clock::time_point> deadline;
450+
if (handshakeTimeoutMs >= 0) {
451+
deadline = std::chrono::steady_clock::now()
452+
+ std::chrono::milliseconds(handshakeTimeoutMs);
453+
}
454+
set_deadline(deadline);
455+
while ((ret = mbedtls_ssl_handshake(&state_->ssl)) == MBEDTLS_ERR_SSL_WANT_READ
456+
|| ret == MBEDTLS_ERR_SSL_WANT_WRITE) {}
457+
const bool timedOut = deadline_hit();
458+
set_deadline({});
459+
if (ret != 0) {
460+
if (ret == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) {
461+
char info[512] = {};
462+
mbedtls_x509_crt_verify_info(info, sizeof info, "",
463+
mbedtls_ssl_get_verify_result(&state_->ssl));
464+
return fail("certificate verification failed: " + one_line(info));
465+
}
466+
if (ret == MBEDTLS_ERR_NET_RECV_FAILED && timedOut) {
467+
return fail("TLS handshake timed out");
442468
}
469+
return fail("TLS handshake failed: " + mbedtls_message(ret));
443470
}
444471

445472
return true;

0 commit comments

Comments
 (0)