Skip to content

Commit 97e2488

Browse files
authored
🔀 Merge pull request #667 from ruby/backport/v0.3/STARTTLS-stripping
🔒 Fix STARTTLS stripping vulnerability (backports #664, #395, #198)
2 parents d86186d + ceeb92a commit 97e2488

4 files changed

Lines changed: 100 additions & 2 deletions

File tree

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ source "https://rubygems.org"
55
gemspec
66

77
gem "rake"
8-
gem "rdoc"
8+
gem "rdoc", "<7.2" # incompatible with ruby 2.6
99
gem "test-unit"

lib/net/imap.rb

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,8 +1014,11 @@ def logout
10141014
# unsolicited untagged response immeditely _after_ #starttls completes.
10151015
#
10161016
def starttls(options = {}, verify = true)
1017-
send_command("STARTTLS") do |resp|
1017+
handled = false
1018+
error = nil
1019+
ok = send_command("STARTTLS") do |resp|
10181020
if resp.kind_of?(TaggedResponse) && resp.name == "OK"
1021+
handled = true
10191022
begin
10201023
# for backward compatibility
10211024
certs = options.to_str
@@ -1024,7 +1027,21 @@ def starttls(options = {}, verify = true)
10241027
end
10251028
start_tls_session(options)
10261029
end
1030+
rescue Exception => error
1031+
raise # note that the error backtrace is in the receiver_thread
10271032
end
1033+
if error
1034+
disconnect
1035+
raise error
1036+
end
1037+
unless handled
1038+
disconnect
1039+
raise InvalidResponseError,
1040+
"STARTTLS handler was bypassed, although server responded %p" % [
1041+
ok.raw_data.chomp
1042+
]
1043+
end
1044+
ok
10281045
end
10291046

10301047
# :call-seq:
@@ -2294,6 +2311,7 @@ def send_command(cmd, *args, &block)
22942311
put_string(" ")
22952312
send_data(i, tag)
22962313
end
2314+
guard_against_tagged_response_skipping_handler!(tag)
22972315
put_string(CRLF)
22982316
if cmd == "LOGOUT"
22992317
@logout_command_tag = tag
@@ -2309,6 +2327,17 @@ def send_command(cmd, *args, &block)
23092327
end
23102328
end
23112329
end
2330+
rescue InvalidResponseError
2331+
disconnect
2332+
raise
2333+
end
2334+
2335+
def guard_against_tagged_response_skipping_handler!(tag)
2336+
return unless (resp = @tagged_responses[tag])&.name&.upcase == "OK"
2337+
raise(InvalidResponseError,
2338+
"Server sent tagged 'OK' before command was finished: %p. " \
2339+
"This could indicate a malicious server or client-side " \
2340+
"command injection. Disconnecting." % [resp.raw_data.chomp])
23122341
end
23132342

23142343
def generate_tag

lib/net/imap/errors.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,27 @@ class BadResponseError < ResponseError
8181
class ByeResponseError < ResponseError
8282
end
8383

84+
# Error raised when the server sends an invalid response.
85+
#
86+
# This is different from UnknownResponseError: the response has been
87+
# rejected. Although it may be parsable, the server is forbidden from
88+
# sending it in the current context. The client should automatically
89+
# disconnect, abruptly (without logout).
90+
#
91+
# Note that InvalidResponseError does not inherit from ResponseError: it
92+
# can be raised before the response is fully parsed. A related
93+
# ResponseParseError or ResponseError may be the #cause.
94+
class InvalidResponseError < Error
95+
end
96+
8497
# Error raised upon an unknown response from the server.
98+
#
99+
# This is different from InvalidResponseError: the response may be a
100+
# valid extension response and the server may be allowed to send it in
101+
# this context, but Net::IMAP either does not know how to parse it or
102+
# how to handle it. This could result from enabling unknown or
103+
# unhandled extensions. The connection may still be usable,
104+
# but—depending on context—it may be prudent to disconnect.
85105
class UnknownResponseError < ResponseError
86106
end
87107

test/net/imap/test_imap.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ def test_imaps_post_connection_check
7575
end
7676

7777
if defined?(OpenSSL::SSL)
78+
def test_starttls_unknown_ca
79+
imap = nil
80+
ex = nil
81+
starttls_test do |port|
82+
imap = Net::IMAP.new("localhost", port: port)
83+
begin
84+
imap.starttls
85+
rescue => ex
86+
end
87+
imap
88+
end
89+
assert_kind_of(OpenSSL::SSL::SSLError, ex)
90+
end
91+
7892
def test_starttls
7993
imap = nil
8094
starttls_test do |port|
@@ -99,6 +113,40 @@ def test_starttls_stripping
99113
imap
100114
end
101115
end
116+
117+
def test_starttls_stripping_ok_sent_before_response
118+
# to coordinate between threads (better than sleep)
119+
server_to_client, client_to_server = Queue.new, Queue.new
120+
imap = nil
121+
server = create_tcp_server
122+
port = server.addr[1]
123+
start_server do
124+
sock = server.accept
125+
begin
126+
sock.print("* OK test server\r\n")
127+
assert_equal :send_malicious_response, client_to_server.pop
128+
sock.print("RUBY0001 OK hahaha, fooled you!\r\n")
129+
server_to_client << :malicious_response_sent
130+
sock.gets
131+
ensure
132+
sock.close
133+
server.close
134+
end
135+
end
136+
begin
137+
imap = Net::IMAP.new("localhost", :port => port)
138+
client_to_server << :send_malicious_response
139+
assert_equal :malicious_response_sent, server_to_client.pop
140+
sleep 0.010 # to be sure the network buffers have flushed, etc
141+
assert_raise(Net::IMAP::InvalidResponseError) do
142+
imap.starttls(:ca_file => CA_FILE)
143+
end
144+
assert imap.disconnected?
145+
ensure
146+
imap.disconnect if imap && !imap.disconnected?
147+
end
148+
assert imap.disconnected?
149+
end
102150
end
103151

104152
def start_server
@@ -1004,6 +1052,7 @@ def starttls_test
10041052
sock.gets
10051053
sock.print("* BYE terminating connection\r\n")
10061054
sock.print("RUBY0002 OK LOGOUT completed\r\n")
1055+
rescue OpenSSL::SSL::SSLError
10071056
ensure
10081057
sock.close
10091058
server.close

0 commit comments

Comments
 (0)