Skip to content

Commit cfdab98

Browse files
committed
Bound the TLS handshake with connectTimeoutMs
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 now sets a deadline on the socket at the bottom for the duration of the handshake. bio_recv waits against it (and against the token, if any) through Socket::wait_before_recv, and a handshake that runs out of time fails with "TLS handshake timed out". The deadline covers the handshake with the target, with an https:// proxy and with the target inside an https:// proxy's tunnel. connect_over takes the timeout as a trailing argument; -1, the default, waits without limit.
1 parent 2cc7310 commit cfdab98

6 files changed

Lines changed: 340 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
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_over` takes a trailing `handshakeTimeoutMs`, `-1` (the
20+
default) for no limit; a call by name compiles unchanged, a pointer to it needs
21+
the new parameter in its type. `Socket` gains `set_deadline`, `deadline_hit`
22+
and `wait_before_recv`.
23+
324
## 0.3.4
425

526
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: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ 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_) {
5656
other.fd_ = INVALID_SOCKET_FD;
5757
}
5858

@@ -62,6 +62,7 @@ public:
6262
close();
6363
fd_ = other.fd_;
6464
stop_ = std::move(other.stop_);
65+
deadline_ = other.deadline_;
6566
other.fd_ = INVALID_SOCKET_FD;
6667
}
6768
return *this;
@@ -77,6 +78,32 @@ public:
7778

7879
[[nodiscard]] bool stop_possible() const { return stop_.stop_possible(); }
7980

81+
// Bounds the TLS handshake: until it is cleared, `wait_before_recv` gives up
82+
// once this time has passed. Cleared once the handshake is over.
83+
void set_deadline(std::optional<std::chrono::steady_clock::time_point> deadline) {
84+
deadline_ = deadline;
85+
deadline_hit_ = false;
86+
}
87+
88+
// True once `wait_before_recv` gave up because of the deadline.
89+
[[nodiscard]] bool deadline_hit() const { return deadline_hit_; }
90+
91+
// What `bio_recv` calls ahead of a `recv` that would otherwise block. True
92+
// when a `recv` now will not block. With neither a token nor a deadline it
93+
// answers true without waiting, so the plain `recv` is as it was.
94+
bool wait_before_recv() {
95+
if (!stop_.stop_possible() && !deadline_) return true;
96+
int wait = -1;
97+
if (deadline_) {
98+
const auto left = std::chrono::ceil<std::chrono::milliseconds>(
99+
*deadline_ - std::chrono::steady_clock::now()).count();
100+
wait = left > 0 ? static_cast<int>(left) : 0;
101+
}
102+
if (wait_readable(wait)) return true;
103+
if (deadline_ && !stop_.stop_requested()) deadline_hit_ = true;
104+
return false;
105+
}
106+
80107
bool connect(const char* host, int port, int timeoutMs) {
81108
// Close existing connection if any
82109
if (is_valid()) {
@@ -342,6 +369,8 @@ public:
342369
private:
343370
SocketHandle fd_ = INVALID_SOCKET_FD;
344371
std::stop_token stop_;
372+
std::optional<std::chrono::steady_clock::time_point> deadline_;
373+
bool deadline_hit_ = false;
345374

346375
// poll_fd in slices while a token is attached. A negative timeout waits
347376
// 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)