From 38636f870dd4558c7632bb19c23d43f4145b7f52 Mon Sep 17 00:00:00 2001 From: kiku98 Date: Tue, 14 Jul 2026 09:43:51 +0200 Subject: [PATCH] fix(smtp-server): treat TLS client disconnects as client-gone, not errors When a client disconnects abruptly, OpenSSL and the socket layer raise errors that the SMTP server logged as genuine server errors (with a full backtrace and Sentry report) despite being routine client-gone events - commonly caused by port scanners, load-balancer health checks and senders whose connection drops. Three paths in the event loop were affected: - Handshake path: a client resetting the connection mid SSL_accept raises Errno::ECONNRESET (not an SSLError), which the handshake rescue did not handle. - Read path: an abrupt disconnect while reading raises OpenSSL::SSL::SSLError ("SSL_read: unexpected eof while reading") rather than an EOFError, which the read rescue did not handle. - Write path: a client gone before the response is written raises Errno::EPIPE ("broken pipe") as well as the already-handled Errno::ECONNRESET. Extend each rescue so an abrupt disconnect on any path is treated the same as a plain EOF: the connection is closed quietly instead of being reported as a server error. --- app/lib/smtp_server/server.rb | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/lib/smtp_server/server.rb b/app/lib/smtp_server/server.rb index 90902b5a8..3ea2368ff 100644 --- a/app/lib/smtp_server/server.rb +++ b/app/lib/smtp_server/server.rb @@ -176,7 +176,11 @@ def run_event_loop # Could not accept without blocking # We will try again later next - rescue OpenSSL::SSL::SSLError => e + rescue OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ETIMEDOUT => e + # Client aborted the TLS handshake, e.g. by resetting the + # connection mid SSL_accept (Errno::ECONNRESET) or letting it + # time out. This is a routine client-gone event, not a server + # error, so treat it the same as a failed negotiation. client.logger&.debug "SSL Negotiation Failed: #{e.message}" eof = true end @@ -190,8 +194,10 @@ def run_event_loop if io.is_a?(OpenSSL::SSL::SSLSocket) buffers[io] << io.readpartial(10_240) while io.pending.positive? end - rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT - # Client went away + rescue EOFError, Errno::ECONNRESET, Errno::ETIMEDOUT, OpenSSL::SSL::SSLError + # Client went away. On a TLS socket an abrupt disconnect surfaces + # as an OpenSSL::SSL::SSLError ("SSL_read: unexpected eof") rather + # than an EOFError, so we treat it the same way. eof = true end @@ -211,8 +217,11 @@ def run_event_loop begin io.write(iline.to_s + "\r\n") io.flush - rescue Errno::ECONNRESET - # Client disconnected before we could write response + rescue Errno::ECONNRESET, Errno::EPIPE + # Client disconnected before we could write the response. + # A closed peer surfaces as ECONNRESET or, when the socket + # is already gone, as EPIPE ("broken pipe") - both mean the + # client went away, not a server error. eof = true end end