Skip to content

Commit 614feb7

Browse files
committed
♻️ Extract InvalidTaggedResponseError
This converts some instances of `InvalidResponseError` into a subclass: `InvalidTaggedResponseError`. This refactors code that was introduced by #664, which raises an exception when the server sends a tagged `OK` response for a command *before* that command has been fully sent. Please note that the exception class documents more error conditions than are currently handled. It also adds a very simple `Command` data object to encapsulate information that's helpful for this error reporting. Future commits may use these command objects to track the state of active commands.
1 parent aab64f9 commit 614feb7

5 files changed

Lines changed: 128 additions & 12 deletions

File tree

lib/net/imap.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3201,7 +3201,8 @@ def idle(timeout = nil, &response_handler)
32013201

32023202
synchronize do
32033203
tag = Thread.current[:net_imap_tag] = generate_tag
3204-
guard_against_tagged_response_skipping_handler!(tag, "IDLE")
3204+
command = Command[tag:, name: "IDLE"]
3205+
finish_sending_command(command)
32053206
put_string("#{tag} IDLE#{CRLF}")
32063207

32073208
begin
@@ -3661,13 +3662,14 @@ def send_command(cmd, *args, &block)
36613662
validate_data(i)
36623663
end
36633664
tag = generate_tag
3665+
command = Command[tag:, name: cmd]
36643666
put_string(tag + " " + cmd)
36653667
args.each do |i|
36663668
put_string(" ")
36673669
send_data(i, tag)
36683670
end
36693671
@logout_command_tag = tag if cmd == "LOGOUT"
3670-
guard_against_tagged_response_skipping_handler!(tag, cmd)
3672+
finish_sending_command(command)
36713673
add_response_handler(&block) if block
36723674
begin
36733675
put_string(CRLF)
@@ -3681,14 +3683,12 @@ def send_command(cmd, *args, &block)
36813683
raise
36823684
end
36833685

3684-
def guard_against_tagged_response_skipping_handler!(tag, cmd)
3685-
return unless (resp = @tagged_responses[tag])&.name&.upcase == "OK"
3686-
raise InvalidResponseError, format(
3687-
"Received tagged 'OK' to incomplete %s command (tag=%s). " \
3688-
"This could indicate a malicious server, a man-in-the-middle, or " \
3689-
"client-side command injection. Disconnecting.",
3690-
cmd, tag
3691-
)
3686+
# NOTE: This must be synchronized with sending the command's final CRLF and
3687+
# adding any command-related response handlers.
3688+
def finish_sending_command(command)
3689+
if (response = @tagged_responses[command.tag])&.name&.casecmp?("OK")
3690+
raise InvalidTaggedResponseError.new(:incomplete, command:, response:)
3691+
end
36923692
end
36933693

36943694
def generate_tag

lib/net/imap/command_data.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ def send_list_data(list, tag = nil)
141141
def send_date_data(date) put_string Net::IMAP.encode_date(date) end
142142
def send_time_data(time) put_string Net::IMAP.encode_time(time) end
143143

144+
# NOTE: Currently for internal use only. The API is expected to change.
145+
Command = Data.define(:tag, :name) # :nodoc:
146+
144147
CommandData = Data.define(:data) do # :nodoc:
145148
def self.validate(...)
146149
data = new(...)

lib/net/imap/errors.rb

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,11 @@ class ByeResponseError < ResponseError
279279
#
280280
# This is different from UnknownResponseError: the response has been
281281
# rejected. Although it may be parsable, the server is forbidden from
282-
# sending it in the current context. The client should automatically
282+
# sending it in the current context.
283+
#
284+
# This could be caused by a bug in the server or in Net::IMAP. Or it
285+
# might indicate a malicious server, a man-in-the-middle attack, or
286+
# client-side command injection. So the client should automatically
283287
# disconnect, abruptly (without logout).
284288
#
285289
# Note that InvalidResponseError does not inherit from ResponseError: it
@@ -288,6 +292,67 @@ class ByeResponseError < ResponseError
288292
class InvalidResponseError < Error
289293
end
290294

295+
# Error raised when the server sends a tagged #response that is invalid for
296+
# the #command #state.
297+
#
298+
# This could be caused by a bug in the server or in Net::IMAP. Or it
299+
# might indicate a malicious server, a man-in-the-middle attack, or
300+
# client-side command injection, so the client should disconnect
301+
# automatically and abruptly (without logout).
302+
class InvalidTaggedResponseError < InvalidResponseError
303+
# A symbol representing the state of the matching tagged command.
304+
#
305+
# +:unknown+::
306+
# #response does not match any known command (#command will be +nil+).
307+
# +:unstarted+::
308+
# Any tagged #response is invalid before #command starts sending.
309+
# +:incomplete+::
310+
# A tagged +OK+ #response is invalid before #command is fully sent.
311+
# +:completed+::
312+
# Multiple tagged responses were received for the same command.
313+
#
314+
# NOTE: Command state is neither anticipated nor remembered indefinitely.
315+
# +:unknown+ is used before the matching command is called and after the
316+
# it is forgotten.
317+
#
318+
# NOTE: This version of Net::IMAP does not detect or raise an exception
319+
# for all of these states.
320+
attr_reader :state
321+
322+
# Metadata about the matching IMAP command.
323+
#
324+
# Returns +nil+ when #state is +:unknown+.
325+
#
326+
# NOTE: The non-nil return type is an unstable API, for debug only. It
327+
# may be changed by any release, without warning or deprecation.
328+
attr_reader :command
329+
330+
# The TaggedResponse which triggered this error
331+
attr_reader :response
332+
333+
def initialize(state, response:, command: nil)
334+
response => TaggedResponse[tag:, name: status]
335+
case [state, status, command]
336+
in :unknown, _, nil
337+
in :incomplete, "OK", {tag: ^tag, name:}
338+
in :unstarted | :completed, _, {tag: ^tag, name:}
339+
end
340+
@state, @command, @response = state, command, response
341+
cmd_desc = name ? "#{state} #{name}" : state
342+
super "Received tagged #{status} to #{cmd_desc} command (tag=#{tag})"
343+
rescue NoMatchingPatternError => err
344+
raise ArgumentError, err.message
345+
end
346+
347+
def detailed_message(**)
348+
"#{message}.\n" \
349+
"Disconnecting: This could indicate a malicious server, a " \
350+
"man-in-the-middle attack, a client-side command injection, or " \
351+
"a bug in net-imap.\n" \
352+
"response.data=#{response.data.inspect}"
353+
end
354+
end
355+
291356
# Error raised upon an unknown response from the server.
292357
#
293358
# This is different from InvalidResponseError: the response may be a

test/net/imap/test_errors.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,52 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m
219219
"exceeds max_response_size (1200B)", err.message)
220220
end
221221

222+
test "InvalidTaggedResponseError" do
223+
assert_raise(ArgumentError) do Net::IMAP::InvalidTaggedResponseError.new end
224+
assert_raise(ArgumentError) do
225+
Net::IMAP::InvalidTaggedResponseError.new("foo", response: nil)
226+
end
227+
assert_raise(ArgumentError) do
228+
Net::IMAP::InvalidTaggedResponseError.new(:unknown, response: nil)
229+
end
230+
231+
response = Net::IMAP::TaggedResponse[
232+
tag: "RUBY0001", name: "NO",
233+
data: Net::IMAP::ResponseText[code: nil, text: "cheating"]
234+
]
235+
assert_raise(ArgumentError) do
236+
Net::IMAP::InvalidTaggedResponseError.new(:unstarted, response:)
237+
end
238+
239+
err = Net::IMAP::InvalidTaggedResponseError.new(:unknown, response:)
240+
assert_match(/tagged NO to unknown command.*tag=RUBY0001\b/i, err.message)
241+
assert_match(/\bdisconnecting\b/i, err.detailed_message)
242+
assert_match(/response.data=#<struct Net::IMAP::ResponseText.+cheating.*>/i,
243+
err.detailed_message)
244+
245+
command = Net::IMAP::Command[tag: "mismatch", name: "NOOP"]
246+
assert_raise(ArgumentError) do
247+
Net::IMAP::InvalidTaggedResponseError.new(:unstarted, response:, command:)
248+
end
249+
250+
command = Net::IMAP::Command[tag: "RUBY0001", name: "NOOP"]
251+
err = Net::IMAP::InvalidTaggedResponseError.new(:unstarted, response:, command:)
252+
assert_match(/tagged NO to unstarted NOOP command.*tag=RUBY0001\b/i,
253+
err.message)
254+
assert_match(/\bdisconnecting\b/i, err.detailed_message)
255+
assert_match(/response.data=#<struct Net::IMAP::ResponseText.+cheating.*>/i,
256+
err.detailed_message)
257+
258+
response = Net::IMAP::TaggedResponse[
259+
tag: "RUBY0001", name: "OK",
260+
data: Net::IMAP::ResponseText[code: nil, text: "cheating"]
261+
]
262+
command = Net::IMAP::Command[tag: "RUBY0001", name: "STARTTLS"]
263+
err = Net::IMAP::InvalidTaggedResponseError.new(:incomplete, response:, command:)
264+
assert_match(/tagged OK to incomplete STARTTLS command.*tag=RUBY0001\b/i,
265+
err.message)
266+
assert_match(/\bdisconnecting\b/i, err.detailed_message)
267+
assert_match(/response.data=#<struct Net::IMAP::ResponseText.+cheating.*>/i,
268+
err.detailed_message)
269+
end
222270
end

test/net/imap/test_imap.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def test_starttls_stripping_ok_sent_before_response
195195
client_to_server << :send_malicious_response
196196
assert_equal :malicious_response_sent, server_to_client.pop
197197
sleep 0.010 # to be sure the network buffers have flushed, etc
198-
assert_raise(Net::IMAP::InvalidResponseError) do
198+
assert_raise(Net::IMAP::InvalidTaggedResponseError) do
199199
imap.starttls(:ca_file => CA_FILE)
200200
end
201201
assert imap.disconnected?

0 commit comments

Comments
 (0)