Net::IMAP: Command Injection via ID command argument
Description
Summary
Two Net::IMAP commands, #id and #enable, do not validate their arguments. Arguments to either command could be used by an attacker to inject arbitrary IMAP commands.
Please note that passing untrusted inputs to these commands is usually inappropriate and expected to be uncommon.
Details
When Net::IMAP#id is called with a hash argument, although the ID field value strings are correctly quoted (escaping quoted specials), they were not validated to prohibit CRLF sequences.
While Net::IMAP#enable does process its arguments for aliases, it does not validate them as valid atoms (or as a list of valid atoms). The #to_s value is sent verbatim.
Impact
This is expected to impact very few users: use of untrusted user input for either command is expected to be very uncommon.
The documentation for #enable explicitly warns that using any arguments that are not in the explicitly supported list may result in undocumented behavior. Using arbitrary untrusted user input for #enable will always be inappropriate.
Although client ID field values will most commonly be static and hardcoded, dynamic input sources may be used. For example, client ID fields may be set by configuration or version numbers. Using untrusted user inputs for client ID fields is expected to be uncommon. But any untrusted inputs to client ID can trivially exploit this vulnerability.
Untrusted inputs to either command may include a CRLF sequence followed by a new IMAP command (like DELETE mailbox). Although this does not directly enable data exfiltration, it could be combined with other attack vectors or knowledge of the target system's attributes, e.g.: shared mail folders or the application's installed response handlers.
Mitigation
Update to a version of net-imap which validates #id and #enable arguments.
Untrusted inputs should _never_ be used for #enable arguments.
If net-imap cannot be upgraded: * do not use untrusted inputs for client ID field values * or add validation that client ID field values must not contain any CR or LF bytes.
Affected products
2Patches
54f81b6904429🍒 pick 1f97168b (#699): 🥅 Validate `#enable` arguments are all atoms
2 files changed · +11 −2
lib/net/imap.rb+3 −2 modified@@ -3108,10 +3108,11 @@ def enable(*capabilities) capabilities = capabilities .flatten .map {|e| ENABLE_ALIASES[e] || e } + .flat_map { _1.is_a?(String) && !_1.empty? ? _1.split(/ /, -1) : [_1] } .uniq - .join(' ') + .map { Atom[_1] } synchronize do - send_command("ENABLE #{capabilities}") + send_command("ENABLE", *capabilities) result = clear_responses("ENABLED").last || [] @utf8_strings ||= result.include? "UTF8=ACCEPT" @utf8_strings ||= result.include? "IMAP4REV2"
test/net/imap/test_imap_enable.rb+8 −0 modified@@ -27,6 +27,14 @@ def test_enable assert_equal %w[CONDSTORE], result1 assert_equal %w[UTF8=ACCEPT], result2 assert_equal [], result3 + + assert_raise(Net::IMAP::DataFormatError) do + imap.enable "injection\r\ninjected logout" + end + assert_empty cmdq + assert_raise(Net::IMAP::DataFormatError) do + imap.enable "foo", "", "bar" + end end end
69da4a490dd1🍒 pick 8d9397ab (#698): 🥅 Validate QuotedString contains only valid bytes
2 files changed · +113 −18
lib/net/imap/command_data.rb+21 −8 modified@@ -150,8 +150,8 @@ def validate end end - # Represents IMAP +text+ data, which covers everything in the IMAP grammar, - # except for +literal+, +literal8+, and the concluding +CRLF+. + # Represents IMAP +text+ or +quoted+ data, which share the same + # validations of decoded #data, and differ only in how they are formatted. # # +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+. # Any multibyte +UTF-8+ character is also allowed when the connection @@ -164,7 +164,7 @@ def validate # # The string's bytes must be valid ASCII or valid UTF-8. The string's # reported encoding is ignored, but the string is _not_ transcoded. - class RawText < CommandData # :nodoc: + class ValidNonLiteralData < CommandData def initialize(data:) data = String(data.to_str) unless data.encoding in Encoding::ASCII | Encoding::UTF_8 @@ -189,7 +189,17 @@ def validate def ascii_only? = data.ascii_only? - def send_data(imap, tag) = imap.__send__(:put_string, data) + def send_data(imap, tag = nil) = imap.__send__(:put_string, formatted) + end + + # Represents IMAP +text+ data, which covers everything in the IMAP grammar, + # except for +literal+, +literal8+, and the concluding +CRLF+. + # + # NOTE: The current implementation does not verify that the connection + # supports UTF-8. Future versions may validate this. + class RawText < ValidNonLiteralData # :nodoc: + # raw: no formatting necessary + alias formatted data end class RawData < CommandData # :nodoc: @@ -268,10 +278,13 @@ def send_data(imap, tag) end end - class QuotedString < CommandData # :nodoc: - def send_data(imap, tag) - imap.__send__(:send_quoted_string, data) - end + # Represents a IMAP +quoted+ string, which can encode any valid ASCII or + # UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes. + # + # NOTE: The current implementation does not verify that the connection + # supports UTF-8. Future versions may validate this. + class QuotedString < ValidNonLiteralData # :nodoc: + def formatted = %("#{data.gsub(/["\\]/, "\\\\\\&")}") end class Literal < Data.define(:data, :non_sync) # :nodoc:
test/net/imap/test_command_data.rb+92 −10 modified@@ -8,6 +8,7 @@ class CommandDataTest < Net::IMAP::TestCase Atom = Net::IMAP::Atom Flag = Net::IMAP::Flag + QuotedString = Net::IMAP::QuotedString Literal = Net::IMAP::Literal Literal8 = Net::IMAP::Literal8 RawText = Net::IMAP::RawText @@ -155,6 +156,83 @@ def send_data(*data, tag: TAG) ], imap.output end + class QuotedStringTest < CommandDataTest + test "quotes ASCII strings (no specials)" do + assert_equal '"INBOX"', QuotedString["INBOX"].formatted + imap.send_data( + QuotedString["INBOX"], + QuotedString["etc"] + ) + assert_equal [ + Output.put_string('"INBOX"'), + Output.put_string('"etc"'), + ], imap.output + imap.clear + end + + test "quotes ASCII strings (atom specials)" do + [ + " with spaces in string ", + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\x7fcontrol_char", + %{(){}[]%*'}, + ].each do |string| + imap.send_data QuotedString[string] + end + assert_equal [ + Output.put_string('" with spaces in string "'), + Output.put_string('"with_parens()"'), + Output.put_string('"with_list_wildcards*"'), + Output.put_string('"with_list_wildcards%"'), + Output.put_string('"with_resp_special]"'), + Output.put_string(%{"with\x7fcontrol_char"}), + Output.put_string(%Q{"(){}[]%*'"}), + ], imap.output + end + + test "escapes quoted specials" do + [ + '"with" "quoted" "specials"', + "\\with\\quoted\\specials\\", + %{(){}[]%*"'\\}, + ].each do |string| + imap.send_data QuotedString[string] + end + assert_equal [ + Output.put_string('"\"with\" \"quoted\" \"specials\""'), + Output.put_string('"\\\\with\\\\quoted\\\\specials\\\\"'), + Output.put_string(%q{"(){}[]%*\"'\\\\"}), + ], imap.output + end + + test "ASCII compatible string with another encodings" do + imap.send_data QuotedString.new("foo bar".encode("cp1252")) + assert_equal [ + Output.put_string('"foo bar"'), + ], imap.output + end + + test "allows ASCII control chars" do + text = QuotedString.new("beep\b beep\b escape!\e delete this:\x1f") + imap.send_data text + assert_equal [ + Output.put_string(%{"beep\b beep\b escape!\e delete this:\x1f"}), + ], imap.output + end + + test "quotes valid UTF-8 multibyte chars" do + imap.send_data QuotedString.new("föó bär") + imap.send_data QuotedString.new("ほげ ふが ぴよ") + assert_equal [ + Output.put_string('"föó bär"'), + Output.put_string('"ほげ ふが ぴよ"'), + ], imap.output + end + end + class RawTextTest < CommandDataTest test "allows ASCII strings with no specials" do imap.send_data( @@ -229,14 +307,20 @@ class RawTextTest < CommandDataTest Output.put_string("ほげ ふが ぴよ"), ], imap.output end + end + SharedValidNonLiteralDataTests = ->(data_type) do data( "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i], "CR" => ["with \r CR", /CR\b.+\bbyte/i], "LF" => ["with \n LF", /LF\b.+\bbyte/i], ) test "invalid ASCII byte" do |(text, error_message)| - try_multiple_encodings(error_message, text) + with_multiple_encodings(text) do |encoded| + assert_raise_with_message(DataFormatError, error_message) do + data_type[encoded] + end + end end # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard: @@ -253,7 +337,11 @@ class RawTextTest < CommandDataTest "windows-1252" => "åêïõü".encode("windows-1252"), ) test "invalid UTF-8" do |text| - try_multiple_encodings(/invalid UTF-8/i, text) + with_multiple_encodings(text) do |encoded| + assert_raise_with_message(DataFormatError, /invalid UTF-8/i) do + data_type[encoded] + end + end end def with_multiple_encodings(data) @@ -262,15 +350,9 @@ def with_multiple_encodings(data) yield data.dup.force_encoding("UTF-8") yield data.dup.force_encoding("cp1252") end - - def try_multiple_encodings(error_message, data) - with_multiple_encodings(data) do |encoded| - assert_raise_with_message(DataFormatError, error_message) do - RawText[encoded] - end - end - end end + QuotedStringTest.class_exec QuotedString, &SharedValidNonLiteralDataTests + RawTextTest .class_exec RawText, &SharedValidNonLiteralDataTests class RawDataTest < CommandDataTest test "simple raw text" do
1369da490dd1b3ce36b63554🍒 pick 94c79576 (#677): 📚⚠️ Boost visibility of raw data argument warnings
1 file changed · +20 −8
lib/net/imap.rb+20 −8 modified@@ -2209,6 +2209,7 @@ def uid_expunge(uid_set) # provided as an array or a string. # See {"Argument translation"}[rdoc-ref:#search@Argument+translation] # and {"Search criteria"}[rdoc-ref:#search@Search+criteria], below. + # <em>Please note</em> the warning for when +criteria+ is a String. # # +return+ options control what kind of information is returned about # messages matching the search +criteria+. Specifying +return+ should force @@ -2619,7 +2620,8 @@ def search(...) # backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+ # capability has been enabled. # - # See #search for documentation of parameters. + # See #search for documentation of parameters. <em>Please note</em> the + # warning for when +criteria+ is a String. # # ==== Capabilities # @@ -2705,7 +2707,8 @@ def fetch(...) # {SequenceSet[...]}[rdoc-ref:SequenceSet@Creating+sequence+sets]. # (For message sequence numbers, use #fetch instead.) # - # +attr+ behaves the same as with #fetch. + # +attr+ behaves the same as with #fetch. <em>Please note</em> the #fetch + # warning on the +attr+ argument. # >>> # *Note:* Servers _MUST_ implicitly include the +UID+ message data item as # part of any +FETCH+ response caused by a +UID+ command, regardless of @@ -2917,8 +2920,10 @@ def uid_move(set, mailbox) # Sends a {SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3] # to search a mailbox for messages that match +search_keys+ and return an - # array of message sequence numbers, sorted by +sort_keys+. +search_keys+ - # are interpreted the same as for #search. + # array of message sequence numbers, sorted by +sort_keys+. + # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. <em>Please note</em> the #search warning for String +criteria+. # #-- # TODO: describe +sort_keys+ @@ -2943,8 +2948,10 @@ def sort(sort_keys, search_keys, charset) # Sends a {UID SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3] # to search a mailbox for messages that match +search_keys+ and return an - # array of unique identifiers, sorted by +sort_keys+. +search_keys+ are - # interpreted the same as for #search. + # array of unique identifiers, sorted by +sort_keys+. + # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. <em>Please note</em> the #search warning for String +criteria+. # # Related: #sort, #search, #uid_search, #thread, #uid_thread # @@ -2958,8 +2965,10 @@ def uid_sort(sort_keys, search_keys, charset) # Sends a {THREAD command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3] # to search a mailbox and return message sequence numbers in threaded - # format, as a ThreadMember tree. +search_keys+ are interpreted the same as - # for #search. + # format, as a ThreadMember tree. + # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. <em>Please note</em> the #search warning for String +criteria+. # # The supported algorithms are: # @@ -2985,6 +2994,9 @@ def thread(algorithm, search_keys, charset) # Similar to #thread, but returns unique identifiers instead of # message sequence numbers. # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. <em>Please note</em> the #search warning for String +criteria+. + # # Related: #thread, #search, #uid_search, #sort, #uid_sort # # ==== Capabilities
a2f61af6b7a7🍒 pick 62a0da6d (#701): 🥅 Validate non-synchronizing literals support
2 files changed · +68 −2
lib/net/imap/command_data.rb+17 −1 modified@@ -86,12 +86,19 @@ def send_binary_literal(*a, **kw); send_literal(*a, **kw, binary: true) end # `non_sync` is an optional tri-state flag: # * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior. - # TODO: raise or warn when capabilities don't allow non_sync. + # NOTE: raises DataFormatError when server doesn't support + # non-synchronizing literal, or literal is too large for LITERAL-. # * `false` -> Force normal synchronizing literal behavior. # * `nil` -> (default) Currently behaves like `false` (will be dynamic). # TODO: Dynamic, based on capabilities and bytesize. def send_literal(str, tag = nil, binary: false, non_sync: nil) synchronize do + if non_sync && !non_sync_literal_allowed?(str.bytesize) + # TODO: check in Printer, so we don't need to close the connection. + @sock.close + raise DataFormatError, "Connection closed: " \ + "Cannot send non-synchronizing literal without known server support" + end prefix = "~" if binary plus = "+" if non_sync put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n") @@ -113,6 +120,15 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil) end end + def non_sync_literal_allowed?(bytesize) + return unless capabilities_cached? + return "+" if capable?("LITERAL+") + return "-" if capable_literal_minus? && bytesize <= 4096 + false + end + + def capable_literal_minus? = capable?("LITERAL-") || capable?("IMAP4rev2") + # NOTE: +num+ should already be an Integer def send_number_data(num) put_string(Integer(num).to_s)
test/net/imap/test_imap.rb+51 −1 modified@@ -801,7 +801,7 @@ def test_raw_data end test("send literal args") do - with_fake_server do |server, imap| + with_fake_server(with_extensions: %w[LITERAL-]) do |server, imap| server.on "TEST", &:done_ok send_args = ->(*args) do imap.__send__(:send_command, "TEST", *args) @@ -850,6 +850,56 @@ def test_raw_data end end + test("send non-synchronizing literals with LITERAL+") do + with_fake_server( + with_extensions: %w[LITERAL+], greeting_capabilities: true, + ) do |server, imap| + def imap.send_test_args(*args) = send_command("TEST", *args) + server.on "TEST", &:done_ok + + # imap.config.max_non_synchronizing_literal = 5_000 + # NOTE: support for automatic non-synchronizing literals added in v0.6 + large = "\xff".b * 5_000 + imap.send_test_args Net::IMAP::Literal[large, nil] + assert_equal("{5000}\r\n#{large}".b, server.commands.pop.args) + + large = "\xff".b * 10_000 + imap.send_test_args Net::IMAP::Literal[large, nil] + assert_equal("{10000}\r\n#{large}".b, server.commands.pop.args) + + imap.send_test_args Net::IMAP::Literal[large, true] + assert_equal("{10000+}\r\n#{large}".b, server.commands.pop.args) + end + end + + test("send non-synchronizing literal that's too large for LITERAL-") do + with_fake_server( + with_extensions: %w[LITERAL-], greeting_capabilities: true, + ignore_abrupt_eof: true + ) do |server, imap| + def imap.send_test_args(*args) = send_command("TEST", *args) + server.on "TEST", &:done_ok + assert_raise(Net::IMAP::DataFormatError) do + imap.send_test_args Net::IMAP::Literal["\xff".b * 5000, true] + end + assert imap.disconnected? + end + end + + test("send non-synchronizing literal without known server support") do + with_fake_server( + with_extensions: %w[LITERAL+], greeting_capabilities: false, + ignore_abrupt_eof: true + ) do |server, imap| + def imap.send_test_args(*args) = send_command("TEST", *args) + server.on "TEST", &:done_ok + assert_raise(Net::IMAP::DataFormatError) do + imap.send_test_args Net::IMAP::Literal["\xff".b * 100, true] + end + assert imap.disconnected? + end + end + def test_disconnect server = create_tcp_server port = server.addr[1]
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3News mentions
0No linked articles in our index yet.