Skip to content

Commit 1f339e2

Browse files
kpumukcodex
andcommitted
THRIFT-6120: Bound TLS accept handshakes
Client: rb Co-Authored-By: OpenAI Codex (GPT-5.6) <codex@openai.com>
1 parent a1d73a8 commit 1f339e2

2 files changed

Lines changed: 224 additions & 9 deletions

File tree

lib/rb/lib/thrift/transport/ssl_server_socket.rb

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
# under the License.
2020
#
2121

22+
require 'io/wait'
2223
require 'socket'
2324

2425
module Thrift
@@ -31,12 +32,72 @@ def initialize(host_or_port, port = nil, ssl_context = nil, client_timeout: DEFA
3132
attr_accessor :ssl_context
3233

3334
def listen
34-
socket = TCPServer.new(@host, @port)
35-
@handle = OpenSSL::SSL::SSLServer.new(socket, @ssl_context)
35+
tcp_server = TCPServer.new(@host, @port)
36+
@handle = OpenSSL::SSL::SSLServer.new(tcp_server, @ssl_context)
37+
@handle.start_immediately = false
38+
@handle
39+
end
40+
41+
def accept
42+
return if @handle.nil?
43+
44+
ssl_socket = @handle.accept
45+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @client_timeout unless @client_timeout.nil? || @client_timeout == 0
46+
ssl_socket.to_io.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1)
47+
accept_ssl_socket(ssl_socket, deadline)
48+
49+
Socket.new.tap do |transport|
50+
transport.timeout = @client_timeout
51+
transport.handle = ssl_socket
52+
end
53+
rescue
54+
close_accepted_socket(ssl_socket)
55+
raise
3656
end
3757

3858
def to_s
3959
"ssl(#{super.to_s})"
4060
end
61+
62+
private
63+
64+
def accept_ssl_socket(ssl_socket, deadline)
65+
return ssl_socket.accept unless deadline
66+
67+
loop do
68+
case ssl_socket.accept_nonblock(exception: false)
69+
when ssl_socket
70+
return ssl_socket
71+
when :wait_readable
72+
wait_for_handshake(ssl_socket, :read, deadline)
73+
when :wait_writable
74+
wait_for_handshake(ssl_socket, :write, deadline)
75+
else
76+
raise OpenSSL::SSL::SSLError, "SSL server socket: Unexpected handshake result"
77+
end
78+
end
79+
end
80+
81+
def wait_for_handshake(ssl_socket, direction, deadline)
82+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
83+
raise_handshake_timeout if remaining <= 0
84+
85+
ready = if direction == :read
86+
ssl_socket.to_io.wait_readable(remaining)
87+
else
88+
ssl_socket.to_io.wait_writable(remaining)
89+
end
90+
raise_handshake_timeout unless ready
91+
end
92+
93+
def raise_handshake_timeout
94+
raise OpenSSL::SSL::SSLError, "SSL server socket: Timed out accepting TLS connection"
95+
end
96+
97+
def close_accepted_socket(ssl_socket)
98+
ssl_socket&.close
99+
rescue StandardError
100+
nil
101+
end
41102
end
42103
end

lib/rb/spec/ssl_server_socket_spec.rb

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@
2727
@socket = Thrift::SSLServerSocket.new(1234)
2828
end
2929

30-
it "should delegate to_io to the underlying SSL server handle" do
30+
it "should delegate to_io to the underlying TCP server handle" do
3131
tcp_server = double("TCPServer")
3232
ssl_server = double("SSLServer")
3333

3434
allow(TCPServer).to receive(:new).with(nil, 1234).and_return(tcp_server)
35-
allow(OpenSSL::SSL::SSLServer).to receive(:new).with(tcp_server, nil).and_return(ssl_server)
35+
expect(OpenSSL::SSL::SSLServer).to receive(:new).with(tcp_server, nil).and_return(ssl_server)
36+
expect(ssl_server).to receive(:start_immediately=).with(false)
3637
allow(ssl_server).to receive(:to_io).and_return(tcp_server)
3738

38-
@socket.listen
39+
expect(@socket.listen).to eq(ssl_server)
3940
expect(@socket.to_io).to eq(tcp_server)
4041
end
4142

@@ -62,17 +63,22 @@
6263
it "should apply the client timeout to accepted sockets" do
6364
tcp_server = double("TCPServer")
6465
ssl_server = double("SSLServer")
65-
sock = double("SSLSocket")
66+
sock = double("TCPSocket")
67+
ssl_sock = double("SSLSocket")
6668

6769
allow(TCPServer).to receive(:new).with(nil, 1234).and_return(tcp_server)
68-
allow(OpenSSL::SSL::SSLServer).to receive(:new).with(tcp_server, nil).and_return(ssl_server)
69-
expect(ssl_server).to receive(:accept).and_return(sock)
70+
expect(OpenSSL::SSL::SSLServer).to receive(:new).with(tcp_server, nil).and_return(ssl_server)
71+
expect(ssl_server).to receive(:start_immediately=).with(false)
72+
expect(ssl_server).to receive(:accept).and_return(ssl_sock)
73+
allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC).and_return(10)
7074
expect(sock).to receive(:setsockopt).with(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
75+
expect(ssl_sock).to receive(:to_io).and_return(sock)
76+
expect(ssl_sock).to receive(:accept_nonblock).with(exception: false).and_return(ssl_sock)
7177

7278
trans = double("Socket")
7379
expect(Thrift::Socket).to receive(:new).and_return(trans)
7480
expect(trans).to receive(:timeout=).with(Thrift::BaseServerTransport::DEFAULT_CLIENT_TIMEOUT)
75-
expect(trans).to receive(:handle=).with(sock)
81+
expect(trans).to receive(:handle=).with(ssl_sock)
7682

7783
@socket.listen
7884
expect(@socket.accept).to eq(trans)
@@ -81,5 +87,153 @@
8187
it "should provide a reasonable to_s" do
8288
expect(@socket.to_s).to eq("ssl(socket(:1234))")
8389
end
90+
91+
it "times out and closes a TCP client that sends no TLS handshake" do
92+
server = build_server(client_timeout: 0.05)
93+
client = TCPSocket.new('127.0.0.1', server.to_io.local_address.ip_port)
94+
accept_thread = capture_accept(server)
95+
96+
expect(accept_thread.join(1)).not_to be_nil
97+
status, error = accept_thread.value
98+
expect(status).to eq(:error)
99+
expect(error).to be_a(OpenSSL::SSL::SSLError)
100+
expect(error.message).to eq("SSL server socket: Timed out accepting TLS connection")
101+
expect { client.read_nonblock(1) }.to raise_error(EOFError)
102+
ensure
103+
client&.close
104+
server&.close
105+
accept_thread&.kill
106+
accept_thread&.join
107+
end
108+
109+
it "uses the same timeout for a stalled partial TLS handshake" do
110+
server = build_server(client_timeout: 0.05)
111+
client = TCPSocket.new('127.0.0.1', server.to_io.local_address.ip_port)
112+
client.write("\x16\x03\x01\x00".b)
113+
accept_thread = capture_accept(server)
114+
115+
expect(accept_thread.join(1)).not_to be_nil
116+
status, error = accept_thread.value
117+
expect(status).to eq(:error)
118+
expect(error).to be_a(OpenSSL::SSL::SSLError)
119+
expect(error.message).to eq("SSL server socket: Timed out accepting TLS connection")
120+
expect { client.read_nonblock(1) }.to raise_error(EOFError)
121+
ensure
122+
client&.close
123+
server&.close
124+
accept_thread&.kill
125+
accept_thread&.join
126+
end
127+
128+
it "accepts a completed TLS handshake and preserves the client timeout" do
129+
server = build_server(client_timeout: 1)
130+
accept_thread = capture_accept(server)
131+
tcp_client = TCPSocket.new('127.0.0.1', server.to_io.local_address.ip_port)
132+
ssl_client = OpenSSL::SSL::SSLSocket.new(tcp_client, OpenSSL::SSL::SSLContext.new)
133+
ssl_client.sync_close = true
134+
135+
ssl_client.connect
136+
expect(accept_thread.join(1)).not_to be_nil
137+
status, transport = accept_thread.value
138+
expect(status).to eq(:ok)
139+
expect(transport).to be_open
140+
expect(transport.timeout).to eq(1)
141+
ensure
142+
transport&.close
143+
ssl_client&.close
144+
tcp_client&.close
145+
server&.close
146+
accept_thread&.kill
147+
accept_thread&.join
148+
end
149+
150+
it "supports resumed client-authenticated TLS sessions" do
151+
context = server_context
152+
context.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
153+
# The legacy client certificate fixture is self-issued; this test exercises
154+
# authenticated session resumption rather than certificate-chain validation.
155+
context.verify_callback = proc { true }
156+
context.min_version = OpenSSL::SSL::TLS1_2_VERSION
157+
context.max_version = OpenSSL::SSL::TLS1_2_VERSION
158+
context.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_SERVER
159+
server = build_server(client_timeout: 1, context: context)
160+
results = Queue.new
161+
accept_thread = Thread.new do
162+
2.times do
163+
results << [:ok, server.accept]
164+
rescue => error
165+
results << [:error, error]
166+
break
167+
end
168+
end
169+
170+
client_context = OpenSSL::SSL::SSLContext.new
171+
client_context.verify_mode = OpenSSL::SSL::VERIFY_NONE
172+
client_context.cert = OpenSSL::X509::Certificate.new(File.read(File.join(ssl_keys_dir, 'client.crt')))
173+
client_context.key = OpenSSL::PKey::RSA.new(File.read(File.join(ssl_keys_dir, 'client.key')))
174+
client_context.min_version = OpenSSL::SSL::TLS1_2_VERSION
175+
client_context.max_version = OpenSSL::SSL::TLS1_2_VERSION
176+
client_context.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT
177+
178+
first_client = build_ssl_client(server, client_context)
179+
first_client.connect
180+
status, first_transport = results.pop
181+
expect(status).to eq(:ok)
182+
session = first_client.session
183+
first_client.close
184+
first_transport.close
185+
186+
second_client = build_ssl_client(server, client_context, session)
187+
client_error = begin
188+
second_client.connect
189+
nil
190+
rescue => error
191+
error
192+
end
193+
status, second_transport = results.pop
194+
195+
expect(status).to eq(:ok), "server accept failed: #{second_transport.inspect}"
196+
expect(client_error).to be_nil
197+
expect(second_client.session_reused?).to be(true)
198+
ensure
199+
first_client&.close
200+
first_transport&.close
201+
second_client&.close
202+
second_transport&.close if status == :ok
203+
server&.close
204+
accept_thread&.kill
205+
accept_thread&.join
206+
end
207+
208+
def build_server(client_timeout:, context: server_context)
209+
Thrift::SSLServerSocket.new('127.0.0.1', 0, context, client_timeout: client_timeout).tap(&:listen)
210+
end
211+
212+
def server_context
213+
context = OpenSSL::SSL::SSLContext.new
214+
context.cert = OpenSSL::X509::Certificate.new(File.read(File.join(ssl_keys_dir, 'server.crt')))
215+
context.key = OpenSSL::PKey::RSA.new(File.read(File.join(ssl_keys_dir, 'server.key')))
216+
context
217+
end
218+
219+
def build_ssl_client(server, context, session = nil)
220+
tcp_client = TCPSocket.new('127.0.0.1', server.to_io.local_address.ip_port)
221+
OpenSSL::SSL::SSLSocket.new(tcp_client, context).tap do |ssl_client|
222+
ssl_client.sync_close = true
223+
ssl_client.session = session unless session.nil?
224+
end
225+
end
226+
227+
def capture_accept(server)
228+
Thread.new do
229+
[:ok, server.accept]
230+
rescue => error
231+
[:error, error]
232+
end
233+
end
234+
235+
def ssl_keys_dir
236+
File.expand_path('../../../test/keys', __dir__)
237+
end
84238
end
85239
end

0 commit comments

Comments
 (0)