Medium severityGHSA Advisory· Published May 9, 2026· Updated May 13, 2026
CVE-2026-42258
CVE-2026-42258
Description
Net::IMAP implements Internet Message Access Protocol (IMAP) client functionality in Ruby. Prior to versions 0.4.24, 0.5.14, and 0.6.4, symbol arguments to commands are vulnerable to a CRLF Injection / IMAP Command injection via Symbol arguments passed to IMAP commands. This issue has been patched in versions 0.4.24, 0.5.14, and 0.6.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
net-imapRubyGems | >= 0.6.0, < 0.6.4 | 0.6.4 |
net-imapRubyGems | >= 0.5.0, < 0.5.14 | 0.5.14 |
net-imapRubyGems | < 0.4.24 | 0.4.24 |
Affected products
1Patches
3aec06996eb87🔀 Merge pull request #663 from ruby/backport/v0.4/raw_data-warnings
6 files changed · +597 −40
lib/net/imap/command_data.rb+109 −8 modified@@ -25,6 +25,7 @@ def validate_data(data) end when Time, Date, DateTime when Symbol + Flag.validate(data) else data.validate end @@ -45,7 +46,7 @@ def send_data(data, tag = nil) when Date send_date_data(data) when Symbol - send_symbol_data(data) + Flag[data].send_data(self, tag) else data.send_data(self, tag) end @@ -129,10 +130,6 @@ def send_list_data(list, tag = nil) def send_date_data(date) put_string Net::IMAP.encode_date(date) end def send_time_data(time) put_string Net::IMAP.encode_time(time) end - def send_symbol_data(symbol) - put_string("\\" + symbol.to_s) - end - # simplistic emulation of CommandData = Data.define(:data) class CommandData # :nodoc: class << self @@ -154,6 +151,12 @@ def eql?(other) self.class === other && to_h.eql?(other.to_h) end # following class definition goes beyond the basic Data.define(:data) ## + def self.validate(...) + data = new(...) + data.validate + data + end + def send_data(imap, tag) raise NoMethodError, "#{self.class} must implement #{__method__}" end @@ -162,15 +165,113 @@ def validate end end + # Represents IMAP +text+ data, which may contain any 7-bit ASCII character, + # except for +NULL+, +CR+, or +LF+. +text+ is extended to allow any + # multibyte +UTF-8+ character when either +UTF8=ACCEPT+ or +IMAP4rev2+ have + # been enabled, or when the server supports only +IMAP4rev2+ and not earlier + # IMAP revisions, or when the server advertises +UTF8=ONLY+. + # + # NOTE: The current implementation does not validate whether the connection + # currently supports UTF-8. Future versions may change. + # + # 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: + def initialize(data:) + data = String(data.to_str) + data = if [Encoding::ASCII, Encoding::UTF_8].include?(data.encoding) + -data + elsif data.ascii_only? + -(data.dup.force_encoding("ASCII")) + else + -(data.dup.force_encoding("UTF-8")) + end + super + validate + end + + def validate + if data.include?("\0") + raise DataFormatError, "NULL byte must be binary literal encoded" + elsif !data.valid_encoding? + raise DataFormatError, "invalid UTF-8 must be literal encoded" + elsif /[\r\n]/.match?(data) + raise DataFormatError, "CR and LF bytes must be literal encoded" + end + end + + def ascii_only?; data.ascii_only? end + + def send_data(imap, tag) imap.__send__(:put_string, data) end + end + class RawData < CommandData # :nodoc: - def send_data(imap, tag) - imap.__send__(:put_string, @data) + def initialize(data:) + data = split_parts(data) + super + validate + end + + def send_data(imap, tag) data.each do _1.send_data(imap, tag) end end + + def validate + return unless RawText === data.last + text = data.last.data + if text.rindex(/~?\{[1-9]\d*\+?\}\z/n) + raise DataFormatError, "RawData cannot end with literal continuation" + end + end + + private + + def split_parts(data) + data = data.b # dups and ensures BINARY encoding + parts = [] + while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n) + text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $' + bytesize = Integer bytesize, 10 + parts << RawText[text] unless text.empty? + parts << extract_literal(data, + binary: binary, + bytesize: bytesize, + non_sync: non_sync) + data[0, bytesize] = "" + end + parts << RawText[data] unless data.empty? + parts + end + + def extract_literal(data, binary:, bytesize:, non_sync:) + if data.bytesize < bytesize + raise DataFormatError, "Too few bytes in string for literal, " \ + "expected: %s, remaining: %s" % [bytesize, data.bytesize] + end + literal = data.byteslice(0, bytesize) + (binary ? Literal8 : Literal).new(data: literal, non_sync: non_sync) end end class Atom < CommandData # :nodoc: + def initialize(**) + super + validate + end + + def validate + data.to_s.ascii_only? \ + or raise DataFormatError, "#{self.class} must be ASCII only" + data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \ + and raise DataFormatError, "#{self.class} must not contain atom-specials" + end + + def send_data(imap, tag) + imap.__send__(:put_string, data.to_s) + end + end + + class Flag < Atom # :nodoc: def send_data(imap, tag) - imap.__send__(:put_string, @data) + imap.__send__(:put_string, "\\#{data}") end end
lib/net/imap.rb+88 −28 modified@@ -460,6 +460,9 @@ module Net # +LITERAL-+, and +SPECIAL-USE+.</em> # # ==== RFC2087: +QUOTA+ + # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported. + # - Obsoleted by <tt>QUOTA=RES-*</tt> [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]], + # although the commands are backward compatible. # - #getquota: returns the resource usage and limits for a quota root # - #getquotaroot: returns the list of quota roots for a mailbox, as well as # their resource usage and limits. @@ -572,6 +575,16 @@ module Net # See FetchData#emailid and FetchData#emailid. # - Updates #status with support for the +MAILBOXID+ status attribute. # + # ==== RFC9208: <tt>QUOTA=RES-*</tt> + # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported. + # - Obsoletes the +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # extension and provides strict semantics for different resource types. + # - #getquota: returns the resource usage and limits for a quota root + # - #getquotaroot: returns the list of quota roots for a mailbox, as well as + # their resource usage and limits. + # - #setquota: sets the resource limits for a given quota root. + # - Updates #status with <tt>"DELETED"</tt> and +DELETED-STORAGE+ attributes. + # # == References # # [{IMAP4rev1}[https://www.rfc-editor.org/rfc/rfc3501.html]]:: @@ -681,14 +694,13 @@ module Net # # === \IMAP Extensions # - # [QUOTA[https://tools.ietf.org/html/rfc9208]]:: - # Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208, - # March 2022, <https://www.rfc-editor.org/info/rfc9208>. + # [QUOTA[https://www.rfc-editor.org/rfc/rfc2087]]:: + # Myers, J., "IMAP4 QUOTA extension", RFC 2087, DOI 10.17487/RFC2087, + # January 1997, <https://www.rfc-editor.org/info/rfc2087>. # - # <em>Note: obsoletes</em> - # RFC-2087[https://tools.ietf.org/html/rfc2087]<em> (January 1997)</em>. - # <em>Net::IMAP does not fully support the RFC9208 updates yet.</em> - # [IDLE[https://tools.ietf.org/html/rfc2177]]:: + # *NOTE*: _obsoleted_ by RFC9208[https://www.rfc-editor.org/rfc/rfc9208] + # (March 2022). + # [IDLE[https://www.rfc-editor.org/rfc/rfc2177]]:: # Leiba, B., "IMAP4 IDLE command", RFC 2177, DOI 10.17487/RFC2177, # June 1997, <https://www.rfc-editor.org/info/rfc2177>. # [NAMESPACE[https://tools.ietf.org/html/rfc2342]]:: @@ -739,9 +751,15 @@ module Net # Gondwana, B., Ed., "IMAP Extension for Object Identifiers", # RFC 8474, DOI 10.17487/RFC8474, September 2018, # <https://www.rfc-editor.org/info/rfc8474>. + # [{QUOTA=RES-*}[https://www.rfc-editor.org/rfc/rfc9208]]:: + # Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208, + # March 2022, <https://www.rfc-editor.org/info/rfc9208>. + # + # Obsoletes RFC2087[https://www.rfc-editor.org/rfc/rfc2087]. # # === IANA registries # * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities] + # * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2] # * {IMAP Response Codes}[https://www.iana.org/assignments/imap-response-codes/imap-response-codes.xhtml] # * {IMAP Mailbox Name Attributes}[https://www.iana.org/assignments/imap-mailbox-name-attributes/imap-mailbox-name-attributes.xhtml] # * {IMAP and JMAP Keywords}[https://www.iana.org/assignments/imap-jmap-keywords/imap-jmap-keywords.xhtml] @@ -1742,12 +1760,18 @@ def xlist(refname, mailbox) # to both admin and user. If this mailbox exists, it returns an array # containing objects of type MailboxQuotaRoot and MailboxQuota. # + # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single + # resource type. This is usually +STORAGE+, but you may need to verify this + # with UntaggedResponse#raw_data. + # # Related: #getquota, #setquota, MailboxQuotaRoot, MailboxQuota # # ===== Capabilities # - # The server's capabilities must include +QUOTA+ - # [RFC2087[https://tools.ietf.org/html/rfc2087]]. + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported + # resource type. def getquotaroot(mailbox) synchronize do send_command("GETQUOTAROOT", mailbox) @@ -1759,41 +1783,59 @@ def getquotaroot(mailbox) end # Sends a {GETQUOTA command [RFC2087 §4.2]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.2] - # along with specified +mailbox+. If this mailbox exists, then an array - # containing a MailboxQuota object is returned. This command is generally - # only available to server admin. + # for the +quota_root+. If this quota root exists, then an array + # containing a MailboxQuota object is returned. + # + # The names of quota roots that are applicable to a particular mailbox can + # be discovered with #getquotaroot. + # + # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single + # resource type. This is usually +STORAGE+, but you may need to verify this + # with UntaggedResponse#raw_data. # # Related: #getquotaroot, #setquota, MailboxQuota # # ===== Capabilities # - # The server's capabilities must include +QUOTA+ - # [RFC2087[https://tools.ietf.org/html/rfc2087]]. - def getquota(mailbox) + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported + # resource type. + def getquota(quota_root) synchronize do - send_command("GETQUOTA", mailbox) + send_command("GETQUOTA", quota_root) clear_responses("QUOTA") end end # Sends a {SETQUOTA command [RFC2087 §4.1]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.1] - # along with the specified +mailbox+ and +quota+. If +quota+ is nil, then - # +quota+ will be unset for that mailbox. Typically one needs to be logged - # in as a server admin for this to work. + # along with the specified +quota_root+ and +storage_limit+. If + # +storage_limit+ is +nil+, resource limits are unset for that quota root. + # If +storage_limit+ is a number, it sets the +STORAGE+ resource limit. + # + # imap.setquota "#user/alice", 100 + # imap.getquota "#user/alice" + # # => [#<struct Net::IMAP::MailboxQuota mailbox="#user/alice" usage=54 quota=100>] + # + # Typically one needs to be logged in as a server admin for this to work. + # + # *NOTE:* Currently, Net::IMAP only supports setting +STORAGE+ quota limits. # # Related: #getquota, #getquotaroot # # ===== Capabilities # - # The server's capabilities must include +QUOTA+ - # [RFC2087[https://tools.ietf.org/html/rfc2087]]. - def setquota(mailbox, quota) - if quota.nil? - data = '()' + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # capability, or both +QUOTASET+ and a capability prefixed with + # <tt>QUOTA=RES-*</tt> {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] + # for each supported resource type. + def setquota(quota_root, storage_limit) + if storage_limit.nil? + list = [] else - data = '(STORAGE ' + quota.to_s + ')' + list = ["STORAGE", Integer(storage_limit)] end - send_command("SETQUOTA", mailbox, RawData.new(data)) + send_command("SETQUOTA", quota_root, list) end # Sends a {SETACL command [RFC4314 §3.1]}[https://www.rfc-editor.org/rfc/rfc4314#section-3.1] @@ -1900,7 +1942,10 @@ def lsub(refname, mailbox) # <tt>STATUS=SIZE</tt> # {[RFC8483]}[https://www.rfc-editor.org/rfc/rfc8483.html]. # - # +DELETED+ requires the server's capabilities to include +IMAP4rev2+. + # +DELETED+ must be supported when the server's capabilities includes + # +IMAP4rev2+. + # or <tt>QUOTA=RES-MESSAGES</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html]. # # +HIGHESTMODSEQ+ requires the server's capabilities to include +CONDSTORE+ # {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]. @@ -2049,6 +2094,14 @@ def uid_expunge(uid_set) # # ===== Search criteria # + # >>> + # When +criteria+ is an Array, elements in the array will be validated and + # formatted. When +criteria+ is a String, it will be sent <em>with + # minimal validation and no encoding or formatting</em>. + # + # <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other + # types of attribute injection attack if unvetted user input is used.</em> + # # For a full list of search criteria, # see [{IMAP4rev1 §6.4.4}[https://www.rfc-editor.org/rfc/rfc3501.html#section-6.4.4]], # or [{IMAP4rev2 §6.4.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.4]], @@ -2136,6 +2189,13 @@ def uid_search(keys, charset = nil) # # +attr+ is a list of attributes to fetch; see the documentation # for FetchData for a list of valid attributes. + # >>> + # When +attr+ is a String, it will be sent <em>with minimal validation and + # no encoding or formatting</em>. When +attr+ is an Array, each String in + # +attr+ will be sent this way. + # + # <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other + # types of attribute injection attack if unvetted user input is used.</em> # # +changedsince+ is an optional integer mod-sequence. It limits results to # messages with a mod-sequence greater than +changedsince+. @@ -3071,7 +3131,7 @@ def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil) end def store_internal(cmd, set, attr, flags, unchangedsince: nil) - attr = RawData.new(attr) if attr.instance_of?(String) + attr = Atom.new(attr) if attr.instance_of?(String) args = [MessageSet.new(set)] args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince args << attr << flags
lib/net/imap/response_data.rb+22 −2 modified@@ -295,6 +295,14 @@ class ResponseText < Struct.new(:code, :text) # because the server doesn't allow deletion of mailboxes with children. # #data is +nil+. # + # ==== <tt>QUOTA=RES-*</tt> response codes + # See {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html#section-4.3]. + # * +OVERQUOTA+ (also in RFC5530[https://www.rfc-editor.org/rfc/rfc5530]), + # with a tagged +NO+ response to an +APPEND+/+COPY+/+MOVE+ command when + # the command would put the target mailbox over any quota, and with an + # untagged +NO+ when a mailbox exceeds a soft quota (which may be caused + # be external events). #data is +nil+. + # # ==== +CONDSTORE+ extension # See {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]. # * +NOMODSEQ+, when selecting a mailbox that does not support @@ -369,12 +377,24 @@ class MailboxList < Struct.new(:attr, :delim, :name) # Net::IMAP#getquotaroot returns an array containing both MailboxQuotaRoot # and MailboxQuota objects. # + # ==== Required capability + # + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # or <tt>QUOTA=RES-STORAGE</tt> + # [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]] capability. class MailboxQuota < Struct.new(:mailbox, :usage, :quota) ## # method: mailbox # :call-seq: mailbox -> string # - # The mailbox with the associated quota. + # The quota root with the associated quota. + # + # NOTE: this was mistakenly named "mailbox". But the quota root's name may + # differ from the mailbox. A single quota root may cover multiple + # mailboxes, and a single mailbox may be governed by multiple quota roots. + + # The quota root with the associated quota. + alias quota_root mailbox ## # method: usage @@ -386,7 +406,7 @@ class MailboxQuota < Struct.new(:mailbox, :usage, :quota) # method: quota # :call-seq: quota -> Integer # - # Quota limit imposed on the mailbox. + # Storage limit imposed on the mailbox. # end
test/net/imap/test_command_data.rb+267 −0 modified@@ -6,8 +6,12 @@ class CommandDataTest < Test::Unit::TestCase DataFormatError = Net::IMAP::DataFormatError + Atom = Net::IMAP::Atom + Flag = Net::IMAP::Flag Literal = Net::IMAP::Literal Literal8 = Net::IMAP::Literal8 + RawText = Net::IMAP::RawText + RawData = Net::IMAP::RawData # simplistic emulation of Output = Data.define(:name, :args) class Output @@ -84,6 +88,66 @@ def send_data(*data, tag: TAG) @imap = FakeCommandWriter.new end + test "Atom" do + imap.send_data(Atom[:INBOX], Atom["INBOX"], Atom["etc"]) + assert_equal [ + Output.put_string("INBOX"), + Output.put_string("INBOX"), + Output.put_string("etc"), + ], imap.output + + imap.clear + # atom may not contain atom-specials + [ + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\0null", + "with\x7fcontrol_char", + '"with_quoted_specials"', + "with_quoted_specials\\", + "with\rCR", + "with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\batom\b/i) do + imap.send_data Atom[symbol] + end + end + assert_empty imap.output + end + + test "Flag" do + imap.send_data(Flag[:Seen], Flag[:Flagged], + Flag["Deleted"], Flag["Answered"]) + assert_equal [ + Output.put_string("\\Seen"), + Output.put_string("\\Flagged"), + Output.put_string("\\Deleted"), + Output.put_string("\\Answered"), + ], imap.output + + imap.clear + # symbol may not contain atom-specials + [ + :"with_parens()", + :"with_list_wildcards*", + :"with_list_wildcards%", + :"with_resp_special]", + :"with\0null", + :"with\x7fcontrol_char", + :'"with_quoted_specials"', + :"with_quoted_specials\\", + :"with\rCR", + :"with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do + imap.send_data Flag[symbol] + end + end + assert_empty imap.output + end + test "Literal" do imap.send_data Literal["foo\r\nbar"] imap.send_data Literal["foo\r\nbar", false] @@ -113,4 +177,207 @@ def send_data(*data, tag: TAG) ], imap.output end + class RawTextTest < CommandDataTest + test "basic ASCII string" do + imap.send_data RawText.new('foo "bar" (baz)') + assert_equal [Output.put_string('foo "bar" (baz)')], imap.output + end + + test "allows IMAP atom-special symbols" do + imap.send_data RawText.new('foo "bar" (baz)') + imap.send_data RawText.new("(){}[]%*\"\\") + imap.send_data RawText.new("(((((((((((((((( unbalanced ]]]]]]]]]]]]]") + assert_equal [ + Output.put_string('foo "bar" (baz)'), + Output.put_string("(){}[]%*\"\\"), + Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"), + ], imap.output + end + + test "ASCII compatible string with another encodings" do + imap.send_data RawText.new("foo bar".encode("cp1252")) + assert_equal [ + Output.put_string("foo bar"), + ], imap.output + end + + test "allows ASCII control chars" do + text = RawText.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 + + 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) + end + + # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard: + # https://www.unicode.org/versions/Unicode17.0.0/core-spec/chapter-3/#G27506 + data( + "incomplete 2 byte sequence" => "\xc3".b, + "invalid 2 byte sequence" => "\xc3\x7f".b, + "incomplete 3 byte sequence" => "\xe0\x80\x80".b, + "invalid 3 byte sequence" => "\xe0\x80\x80".b, + "incomplete 4 byte sequence" => "\xf1\x80\x80".b, + "invalid 4 byte sequence" => "\xf0\x80\x80\x80".b, + "first byte too high" => "\xff\xaa\xaa\xaa".b, + "UTF-16 surrogate pair" => "\xFE\xFF\xD8\x3D\xDC\xA3\xFE\x0F".b, + "windows-1252" => "åêïõü".encode("windows-1252"), + ) + test "invalid UTF-8" do |text| + try_multiple_encodings(/invalid UTF-8/i, text) + end + + def with_multiple_encodings(data) + yield data.b # BINARY + yield data.dup.force_encoding("ASCII") + 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 + + class RawDataTest < CommandDataTest + test "simple raw text" do + raw = RawData.new('foo "bar" baz') + assert_equal [RawText['foo "bar" baz']], raw.data + imap.send_data raw + assert_equal [Output.put_string('foo "bar" baz')], imap.output + end + + test "a single literal" do + raw = RawData.new("{7}\r\nfoo bar") + assert_equal [Literal["foo bar", false]], raw.data + imap.send_data raw, tag: "t1" + assert_equal [ + Output.send_literal("foo bar", "t1", non_sync: false), + ], imap.output + end + + test "literals embedded between text" do + raw = RawData.new("foo bar {3}\r\nbaz {4+}\r\nquux etc") + assert_equal [ + RawText["foo bar "], + Literal["baz", false], + RawText[" "], + Literal["quux", true], # non-synchronizing + RawText[" etc"], + ], raw.data + imap.send_data raw, tag: "t2" + assert_equal [ + Output.put_string("foo bar "), + Output.send_literal("baz", "t2", non_sync: false), + Output.put_string(" "), + Output.send_literal("quux", "t2", non_sync: true), + Output.put_string(" etc"), + ], imap.output + end + + test "empty literals" do + raw = RawData.new("{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n") + assert_equal [ + Literal["", false], + Literal["", true], + Literal8["", false], + Literal8["", true], + ], raw.data + imap.send_data raw, tag: "t2.2" + assert_equal [ + Output.send_literal("", "t2.2", non_sync: false), + Output.send_literal("", "t2.2", non_sync: true), + Output.send_binary_literal("", "t2.2", non_sync: false), + Output.send_binary_literal("", "t2.2", non_sync: true), + ], imap.output + end + + test "raw text embedded between literals" do + raw = RawData.new("{3}\r\nfoo bar") + assert_equal [ + Literal["foo", false], + RawText[" bar"] + ], raw.data + imap.send_data raw, tag: "t3" + assert_equal [ + Output.send_literal("foo", "t3", non_sync: false), + Output.put_string(" bar"), + ], imap.output + end + + test "raw text followed by literal" do + raw = RawData.new("foo {3}\r\nbar") + assert_equal [ + RawText["foo "], + Literal["bar", false], + ], raw.data + imap.send_data raw, tag: "t4" + assert_equal [ + Output.put_string("foo "), + Output.send_literal("bar", "t4", non_sync: false), + ], imap.output + imap.clear + end + + test "binary literal with regular literal" do + raw = RawData.new("foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux") + assert_equal [ + RawText["foo "], + Literal8["\0bar\r\nb", false], + RawText["az "], + Literal["quux", false], + ], raw.data + imap.send_data raw, tag: "t5" + assert_equal [ + Output.put_string("foo "), + Output.send_binary_literal("\0bar\r\nb", "t5", non_sync: false), + Output.put_string("az "), + Output.send_literal("quux", "t5", non_sync: false), + ], imap.output + end + + data( + "CR" => "with \r byte", + "LF" => "with \n byte", + "NULL" => "with \0 byte", + "CRLF" => "with \r\n bytes", + ) + test "invalid bytes in raw text" do |data| + assert_raise_with_message(DataFormatError, /must be.* literal encoded/i) do + RawData.new(data: data) + end + end + + test "invalid literal" do |data| + assert_raise_with_message(DataFormatError, /too few bytes/i) do + RawData.new(data: "invalid literal {123}\r\ntoo small") + end + + assert_raise_with_message(DataFormatError, /NULL byte.*in.*literal/i) do + RawData.new(data: "invalid literal {10}\r\ncontains \0 null") + end + end + + test "invalid literal ending ('{123}')" do + assert_raise(DataFormatError) do RawData.new(data: "literal {123}") end + assert_raise(DataFormatError) do RawData.new(data: "literal+ {123+}") end + assert_raise(DataFormatError) do RawData.new(data: "~literal ~{123}") end + assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{123+}") end + raw = RawData.new(data: " {123} ") + assert_equal [RawText[" {123} "]], raw.data + end + end + end
test/net/imap/test_imap_quota.rb+52 −0 added@@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require_relative "fake_server" + +class IMAPQuotaTest < Test::Unit::TestCase + include Net::IMAP::FakeServer::TestHelper + + def setup + Net::IMAP.config.reset + @do_not_reverse_lookup = Socket.do_not_reverse_lookup + Socket.do_not_reverse_lookup = true + @threads = [] + end + + def teardown + if !@threads.empty? + assert_join_threads(@threads) + end + ensure + Socket.do_not_reverse_lookup = @do_not_reverse_lookup + end + + test "#setquota(quota_root, limit)" do + with_fake_server do |server, imap| + server.on "SETQUOTA", &:done_ok + + # integer arg + imap.setquota "INBOX", 512 + rcvd_cmd = server.commands.pop + assert_equal "SETQUOTA", rcvd_cmd.name + assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args + + # string arg + imap.setquota "INBOX", "512" + rcvd_cmd = server.commands.pop + assert_equal "SETQUOTA", rcvd_cmd.name + assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args + + # empty quota root, null limit + imap.setquota "", nil + rcvd_cmd = server.commands.pop + assert_equal "SETQUOTA", rcvd_cmd.name + assert_equal '"" ()', rcvd_cmd.args + + assert_raise_with_message(ArgumentError, /invalid value for Integer/) do + imap.setquota "INBOX", "512 620" + end + end + end +end
test/net/imap/test_imap.rb+59 −2 modified@@ -652,6 +652,63 @@ def test_send_invalid_number end end + def test_send_symbol_as_flag + with_fake_server do |server, imap| + server.on "TEST", &:done_ok + + imap.__send__(:send_command, "TEST", :Seen, :Flagged) + assert_equal "\\Seen \\Flagged", server.commands.pop.args + + # symbol may not contain atom-specials + [ + :"with_parens()", + :"with_list_wildcards*", + :"with_list_wildcards%", + :"with_resp_special]", + :"with\0null", + :"with\x7fcontrol_char", + :'"with_quoted_specials"', + :"with_quoted_specials\\", + :"with\rCR", + :"with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do + imap.__send__(:send_command, "TEST", symbol) + end + assert_empty server.commands + end + end + end + + def test_raw_data + with_fake_server do |server, imap| + server.on "TEST", &:done_ok + + imap.__send__(:send_command, "TEST", Net::IMAP::RawData.new("foo bar")) + assert_equal "foo bar", server.commands.pop.args + + imap.__send__(:send_command, "TEST", + Net::IMAP::RawData.new("{3}\r\nfoo"), + Net::IMAP::RawData.new("~{4}\r\n\0bar")) + assert_equal "{3}\r\nfoo ~{4}\r\n\0bar", server.commands.pop.args + + # RawData must pass basic validation before sending command + [ + "with \0 NULL", + "with \r CR", + "with \n LF", + "with \r\n CRLF", + "{1234}\r\nliteral is too small", + "{1}\r\n\0 literal contains NULL", + ].each do |data| + assert_raise(Net::IMAP::DataFormatError) do + imap.__send__(:send_command, "TEST", Net::IMAP::RawData[data: data]) + end + assert_empty server.commands + end + end + end + test("send literal args") do with_fake_server do |server, imap| server.on "TEST", &:done_ok @@ -1167,9 +1224,9 @@ def test_enable test "#uid_store with changedsince" do with_fake_server select: "inbox" do |server, imap| server.on("UID STORE", &:done_ok) - imap.uid_store 1..-1, "FLAGS", %i[Deleted], unchangedsince: 987 + imap.uid_store 1..-1, "+FLAGS.SILENT", %i[Deleted], unchangedsince: 987 assert_equal( - "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) FLAGS (\\Deleted)", + "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) +FLAGS.SILENT (\\Deleted)", server.commands.pop.raw.strip ) end
6bf02aef7e0b🔀 Merge pull request #662 from ruby/backport/v0.5/raw_data-warnings
7 files changed · +572 −45
lib/net/imap/command_data.rb+105 −8 modified@@ -28,6 +28,7 @@ def validate_data(data) end when Time, Date, DateTime when Symbol + Flag.validate(data) else data.validate end @@ -48,7 +49,7 @@ def send_data(data, tag = nil) when Date send_date_data(data) when Symbol - send_symbol_data(data) + Flag[data].send_data(self, tag) else data.send_data(self, tag) end @@ -132,11 +133,13 @@ def send_list_data(list, tag = nil) def send_date_data(date) put_string Net::IMAP.encode_date(date) end def send_time_data(time) put_string Net::IMAP.encode_time(time) end - def send_symbol_data(symbol) - put_string("\\" + symbol.to_s) - end - CommandData = Data.define(:data) do # :nodoc: + def self.validate(...) + data = new(...) + data.validate + data + end + def send_data(imap, tag) raise NoMethodError, "#{self.class} must implement #{__method__}" end @@ -145,15 +148,109 @@ def validate end end + # Represents IMAP +text+ data, which may contain any 7-bit ASCII character, + # except for +NULL+, +CR+, or +LF+. +text+ is extended to allow any + # multibyte +UTF-8+ character when either +UTF8=ACCEPT+ or +IMAP4rev2+ have + # been enabled, or when the server supports only +IMAP4rev2+ and not earlier + # IMAP revisions, or when the server advertises +UTF8=ONLY+. + # + # NOTE: The current implementation does not validate whether the connection + # currently supports UTF-8. Future versions may change. + # + # 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: + def initialize(data:) + data = String(data.to_str) + data = if data.encoding in Encoding::ASCII | Encoding::UTF_8 + -data + elsif data.ascii_only? + -(data.dup.force_encoding("ASCII")) + else + -(data.dup.force_encoding("UTF-8")) + end + super + validate + end + + def validate + if data.include?("\0") + raise DataFormatError, "NULL byte must be binary literal encoded" + elsif !data.valid_encoding? + raise DataFormatError, "invalid UTF-8 must be literal encoded" + elsif /[\r\n]/.match?(data) + raise DataFormatError, "CR and LF bytes must be literal encoded" + end + end + + def ascii_only? = data.ascii_only? + + def send_data(imap, tag) = imap.__send__(:put_string, data) + end + class RawData < CommandData # :nodoc: - def send_data(imap, tag) - imap.__send__(:put_string, data) + def initialize(data:) + data = split_parts(data) + super + validate + end + + def send_data(imap, tag) = data.each do _1.send_data(imap, tag) end + + def validate + return unless data.last in RawText(data: text) + if text.rindex(/~?\{[1-9]\d*\+?\}\z/n) + raise DataFormatError, "RawData cannot end with literal continuation" + end + end + + private + + def split_parts(data) + data = data.b # dups and ensures BINARY encoding + parts = [] + while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n) + text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $' + bytesize = Integer bytesize, 10 + parts << RawText[text] unless text.empty? + parts << extract_literal(data, binary:, bytesize:, non_sync:) + data[0, bytesize] = "" + end + parts << RawText[data] unless data.empty? + parts + end + + def extract_literal(data, binary:, bytesize:, non_sync:) + if data.bytesize < bytesize + raise DataFormatError, "Too few bytes in string for literal, " \ + "expected: %s, remaining: %s" % [bytesize, data.bytesize] + end + literal = data.byteslice(0, bytesize) + (binary ? Literal8 : Literal).new(data: literal, non_sync:) end end class Atom < CommandData # :nodoc: + def initialize(**) + super + validate + end + + def validate + data.to_s.ascii_only? \ + or raise DataFormatError, "#{self.class} must be ASCII only" + data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \ + and raise DataFormatError, "#{self.class} must not contain atom-specials" + end + + def send_data(imap, tag) + imap.__send__(:put_string, data.to_s) + end + end + + class Flag < Atom # :nodoc: def send_data(imap, tag) - imap.__send__(:put_string, data) + imap.__send__(:put_string, "\\#{data}") end end
lib/net/imap.rb+84 −32 modified@@ -462,6 +462,9 @@ module Net # +LITERAL-+, and +SPECIAL-USE+.</em> # # ==== RFC2087: +QUOTA+ + # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported. + # - Obsoleted by <tt>QUOTA=RES-*</tt> [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]], + # although the commands are backward compatible. # - #getquota: returns the resource usage and limits for a quota root # - #getquotaroot: returns the list of quota roots for a mailbox, as well as # their resource usage and limits. @@ -578,6 +581,16 @@ module Net # See FetchData#emailid and FetchData#emailid. # - Updates #status with support for the +MAILBOXID+ status attribute. # + # ==== RFC9208: <tt>QUOTA=RES-*</tt> + # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported. + # - Obsoletes the +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # extension and provides strict semantics for different resource types. + # - #getquota: returns the resource usage and limits for a quota root + # - #getquotaroot: returns the list of quota roots for a mailbox, as well as + # their resource usage and limits. + # - #setquota: sets the resource limits for a given quota root. + # - Updates #status with <tt>"DELETED"</tt> and +DELETED-STORAGE+ attributes. + # # ==== RFC9394: +PARTIAL+ # - Updates #search, #uid_search with the +PARTIAL+ return option which adds # ESearchResult#partial return data. @@ -698,13 +711,12 @@ module Net # # === \IMAP Extensions # - # [QUOTA[https://www.rfc-editor.org/rfc/rfc9208]]:: - # Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208, - # March 2022, <https://www.rfc-editor.org/info/rfc9208>. + # [QUOTA[https://www.rfc-editor.org/rfc/rfc2087]]:: + # Myers, J., "IMAP4 QUOTA extension", RFC 2087, DOI 10.17487/RFC2087, + # January 1997, <https://www.rfc-editor.org/info/rfc2087>. # - # <em>Note: obsoletes</em> - # RFC-2087[https://www.rfc-editor.org/rfc/rfc2087]<em> (January 1997)</em>. - # <em>Net::IMAP does not fully support the RFC9208 updates yet.</em> + # *NOTE*: _obsoleted_ by RFC9208[https://www.rfc-editor.org/rfc/rfc9208] + # (March 2022). # [IDLE[https://www.rfc-editor.org/rfc/rfc2177]]:: # Leiba, B., "IMAP4 IDLE command", RFC 2177, DOI 10.17487/RFC2177, # June 1997, <https://www.rfc-editor.org/info/rfc2177>. @@ -756,6 +768,11 @@ module Net # Gondwana, B., Ed., "IMAP Extension for Object Identifiers", # RFC 8474, DOI 10.17487/RFC8474, September 2018, # <https://www.rfc-editor.org/info/rfc8474>. + # [{QUOTA=RES-*}[https://www.rfc-editor.org/rfc/rfc9208]]:: + # Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208, + # March 2022, <https://www.rfc-editor.org/info/rfc9208>. + # + # Obsoletes RFC2087[https://www.rfc-editor.org/rfc/rfc2087]. # [PARTIAL[https://www.rfc-editor.org/info/rfc9394]]:: # Melnikov, A., Achuthan, A., Nagulakonda, V., and L. Alves, # "IMAP PARTIAL Extension for Paged SEARCH and FETCH", RFC 9394, @@ -769,6 +786,7 @@ module Net # # === IANA registries # * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities] + # * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2] # * {IMAP Response Codes}[https://www.iana.org/assignments/imap-response-codes/imap-response-codes.xhtml] # * {IMAP Mailbox Name Attributes}[https://www.iana.org/assignments/imap-mailbox-name-attributes/imap-mailbox-name-attributes.xhtml] # * {IMAP and JMAP Keywords}[https://www.iana.org/assignments/imap-jmap-keywords/imap-jmap-keywords.xhtml] @@ -779,8 +797,8 @@ module Net # * {GSSAPI/Kerberos/SASL Service Names}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml]: # +imap+ # * {Character sets}[https://www.iana.org/assignments/character-sets/character-sets.xhtml] + # # ==== For currently unsupported features: - # * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2] # * {LIST-EXTENDED options and responses}[https://www.iana.org/assignments/imap-list-extended/imap-list-extended.xhtml] # * {IMAP METADATA Server Entry and Mailbox Entry Registries}[https://www.iana.org/assignments/imap-metadata/imap-metadata.xhtml] # * {IMAP ANNOTATE Extension Entries and Attributes}[https://www.iana.org/assignments/imap-annotate-extension/imap-annotate-extension.xhtml] @@ -1828,12 +1846,18 @@ def xlist(refname, mailbox) # to both admin and user. If this mailbox exists, it returns an array # containing objects of type MailboxQuotaRoot and MailboxQuota. # + # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single + # resource type. This is usually +STORAGE+, but you may need to verify this + # with UntaggedResponse#raw_data. + # # Related: #getquota, #setquota, MailboxQuotaRoot, MailboxQuota # # ==== Capabilities # - # The server's capabilities must include +QUOTA+ - # [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]. + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported + # resource type. def getquotaroot(mailbox) synchronize do send_command("GETQUOTAROOT", mailbox) @@ -1845,41 +1869,59 @@ def getquotaroot(mailbox) end # Sends a {GETQUOTA command [RFC2087 §4.2]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.2] - # along with specified +mailbox+. If this mailbox exists, then an array - # containing a MailboxQuota object is returned. This command is generally - # only available to server admin. + # for the +quota_root+. If this quota root exists, then an array + # containing a MailboxQuota object is returned. + # + # The names of quota roots that are applicable to a particular mailbox can + # be discovered with #getquotaroot. + # + # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single + # resource type. This is usually +STORAGE+, but you may need to verify this + # with UntaggedResponse#raw_data. # # Related: #getquotaroot, #setquota, MailboxQuota # # ==== Capabilities # - # The server's capabilities must include +QUOTA+ - # [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]. - def getquota(mailbox) + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported + # resource type. + def getquota(quota_root) synchronize do - send_command("GETQUOTA", mailbox) + send_command("GETQUOTA", quota_root) clear_responses("QUOTA") end end # Sends a {SETQUOTA command [RFC2087 §4.1]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.1] - # along with the specified +mailbox+ and +quota+. If +quota+ is nil, then - # +quota+ will be unset for that mailbox. Typically one needs to be logged - # in as a server admin for this to work. + # along with the specified +quota_root+ and +storage_limit+. If + # +storage_limit+ is +nil+, resource limits are unset for that quota root. + # If +storage_limit+ is a number, it sets the +STORAGE+ resource limit. + # + # imap.setquota "#user/alice", 100 + # imap.getquota "#user/alice" + # # => [#<struct Net::IMAP::MailboxQuota mailbox="#user/alice" usage=54 quota=100>] + # + # Typically one needs to be logged in as a server admin for this to work. + # + # *NOTE:* Currently, Net::IMAP only supports setting +STORAGE+ quota limits. # # Related: #getquota, #getquotaroot # # ==== Capabilities # - # The server's capabilities must include +QUOTA+ - # [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]. - def setquota(mailbox, quota) - if quota.nil? - data = '()' + # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] + # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported + # resource type. + def setquota(quota_root, storage_limit) + if storage_limit.nil? + list = [] else - data = '(STORAGE ' + quota.to_s + ')' + list = ["STORAGE", Integer(storage_limit)] end - send_command("SETQUOTA", mailbox, RawData.new(data)) + send_command("SETQUOTA", quota_root, list) end # Sends a {SETACL command [RFC4314 §3.1]}[https://www.rfc-editor.org/rfc/rfc4314#section-3.1] @@ -1986,7 +2028,10 @@ def lsub(refname, mailbox) # <tt>STATUS=SIZE</tt> # {[RFC8483]}[https://www.rfc-editor.org/rfc/rfc8483.html]. # - # +DELETED+ requires the server's capabilities to include +IMAP4rev2+. + # +DELETED+ must be supported when the server's capabilities includes + # +IMAP4rev2+. + # or <tt>QUOTA=RES-MESSAGES</tt> + # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html]. # # +HIGHESTMODSEQ+ requires the server's capabilities to include +CONDSTORE+ # {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]. @@ -2267,11 +2312,11 @@ def uid_expunge(uid_set) # Encoded as an \IMAP date (see ::encode_date). # # [When +criteria+ is a String] - # +criteria+ will be sent directly to the server <em>without any - # validation or encoding</em>. + # +criteria+ will be sent to the server <em>with minimal validation and no + # encoding or formatting</em>. # - # <em>*WARNING:* This is vulnerable to injection attacks when external - # inputs are used.</em> + # <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other + # types of attribute injection attack if unvetted user input is used.</em> # # ==== Supported return options # @@ -2592,6 +2637,13 @@ def uid_search(...) # # +attr+ is a list of attributes to fetch; see FetchStruct documentation for # a list of supported attributes. + # >>> + # When +attr+ is a String, it will be sent <em>with minimal validation and + # no encoding or formatting</em>. When +attr+ is an Array, each String in + # +attr+ will be sent this way. + # + # <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other + # types of attribute injection attack if unvetted user input is used.</em> # # +changedsince+ is an optional integer mod-sequence. It limits results to # messages with a mod-sequence greater than +changedsince+. @@ -3712,7 +3764,7 @@ def fetch_internal(cmd, set, attr, mod = nil, partial: nil, changedsince: nil) end def store_internal(cmd, set, attr, flags, unchangedsince: nil) - attr = RawData.new(attr) if attr.instance_of?(String) + attr = Atom.new(attr) if attr.instance_of?(String) args = [SequenceSet.new(set)] args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince args << attr << flags
lib/net/imap/response_data.rb+20 −3 modified@@ -307,6 +307,14 @@ class ResponseText < Struct.new(:code, :text) # because the server doesn't allow deletion of mailboxes with children. # #data is +nil+. # + # === <tt>QUOTA=RES-*</tt> response codes + # See {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html#section-4.3]. + # * +OVERQUOTA+ (also in RFC5530[https://www.rfc-editor.org/rfc/rfc5530]), + # with a tagged +NO+ response to an +APPEND+/+COPY+/+MOVE+ command when + # the command would put the target mailbox over any quota, and with an + # untagged +NO+ when a mailbox exceeds a soft quota (which may be caused + # be external events). #data is +nil+. + # # === +CONDSTORE+ extension # See {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html]. # * +NOMODSEQ+, when selecting a mailbox that does not support @@ -384,14 +392,23 @@ class MailboxList < Struct.new(:attr, :delim, :name) # and MailboxQuota objects. # # == Required capability + # # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]] - # capability. + # or <tt>QUOTA=RES-STORAGE</tt> + # [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]] capability. class MailboxQuota < Struct.new(:mailbox, :usage, :quota) ## # method: mailbox # :call-seq: mailbox -> string # - # The mailbox with the associated quota. + # The quota root with the associated quota. + # + # NOTE: this was mistakenly named "mailbox". But the quota root's name may + # differ from the mailbox. A single quota root may cover multiple + # mailboxes, and a single mailbox may be governed by multiple quota roots. + + # The quota root with the associated quota. + alias quota_root mailbox ## # method: usage @@ -403,7 +420,7 @@ class MailboxQuota < Struct.new(:mailbox, :usage, :quota) # method: quota # :call-seq: quota -> Integer # - # Quota limit imposed on the mailbox. + # Storage limit imposed on the mailbox. # end
test/net/imap/test_command_data.rb+267 −0 modified@@ -6,8 +6,12 @@ class CommandDataTest < Net::IMAP::TestCase DataFormatError = Net::IMAP::DataFormatError + Atom = Net::IMAP::Atom + Flag = Net::IMAP::Flag Literal = Net::IMAP::Literal Literal8 = Net::IMAP::Literal8 + RawText = Net::IMAP::RawText + RawData = Net::IMAP::RawData Output = Net::IMAP::Data.define(:name, :args, :kwargs) TAG = Module.new.freeze @@ -60,6 +64,66 @@ def send_data(*data, tag: TAG) @imap = FakeCommandWriter.new end + test "Atom" do + imap.send_data(Atom[:INBOX], Atom["INBOX"], Atom["etc"]) + assert_equal [ + Output.put_string("INBOX"), + Output.put_string("INBOX"), + Output.put_string("etc"), + ], imap.output + + imap.clear + # atom may not contain atom-specials + [ + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\0null", + "with\x7fcontrol_char", + '"with_quoted_specials"', + "with_quoted_specials\\", + "with\rCR", + "with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\batom\b/i) do + imap.send_data Atom[symbol] + end + end + assert_empty imap.output + end + + test "Flag" do + imap.send_data(Flag[:Seen], Flag[:Flagged], + Flag["Deleted"], Flag["Answered"]) + assert_equal [ + Output.put_string("\\Seen"), + Output.put_string("\\Flagged"), + Output.put_string("\\Deleted"), + Output.put_string("\\Answered"), + ], imap.output + + imap.clear + # symbol may not contain atom-specials + [ + :"with_parens()", + :"with_list_wildcards*", + :"with_list_wildcards%", + :"with_resp_special]", + :"with\0null", + :"with\x7fcontrol_char", + :'"with_quoted_specials"', + :"with_quoted_specials\\", + :"with\rCR", + :"with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do + imap.send_data Flag[symbol] + end + end + assert_empty imap.output + end + test "Literal" do imap.send_data Literal["foo\r\nbar"] imap.send_data Literal["foo\r\nbar", false] @@ -89,4 +153,207 @@ def send_data(*data, tag: TAG) ], imap.output end + class RawTextTest < CommandDataTest + test "basic ASCII string" do + imap.send_data RawText.new('foo "bar" (baz)') + assert_equal [Output.put_string('foo "bar" (baz)')], imap.output + end + + test "allows IMAP atom-special symbols" do + imap.send_data RawText.new('foo "bar" (baz)') + imap.send_data RawText.new("(){}[]%*\"\\") + imap.send_data RawText.new("(((((((((((((((( unbalanced ]]]]]]]]]]]]]") + assert_equal [ + Output.put_string('foo "bar" (baz)'), + Output.put_string("(){}[]%*\"\\"), + Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"), + ], imap.output + end + + test "ASCII compatible string with another encodings" do + imap.send_data RawText.new("foo bar".encode("cp1252")) + assert_equal [ + Output.put_string("foo bar"), + ], imap.output + end + + test "allows ASCII control chars" do + text = RawText.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 + + 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) + end + + # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard: + # https://www.unicode.org/versions/Unicode17.0.0/core-spec/chapter-3/#G27506 + data( + "incomplete 2 byte sequence" => "\xc3".b, + "invalid 2 byte sequence" => "\xc3\x7f".b, + "incomplete 3 byte sequence" => "\xe0\x80\x80".b, + "invalid 3 byte sequence" => "\xe0\x80\x80".b, + "incomplete 4 byte sequence" => "\xf1\x80\x80".b, + "invalid 4 byte sequence" => "\xf0\x80\x80\x80".b, + "first byte too high" => "\xff\xaa\xaa\xaa".b, + "UTF-16 surrogate pair" => "\xFE\xFF\xD8\x3D\xDC\xA3\xFE\x0F".b, + "windows-1252" => "åêïõü".encode("windows-1252"), + ) + test "invalid UTF-8" do |text| + try_multiple_encodings(/invalid UTF-8/i, text) + end + + def with_multiple_encodings(data) + yield data.b # BINARY + yield data.dup.force_encoding("ASCII") + 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 + + class RawDataTest < CommandDataTest + test "simple raw text" do + raw = RawData.new('foo "bar" baz') + assert_equal [RawText['foo "bar" baz']], raw.data + imap.send_data raw + assert_equal [Output.put_string('foo "bar" baz')], imap.output + end + + test "a single literal" do + raw = RawData.new("{7}\r\nfoo bar") + assert_equal [Literal["foo bar", false]], raw.data + imap.send_data raw, tag: "t1" + assert_equal [ + Output.send_literal("foo bar", "t1", non_sync: false), + ], imap.output + end + + test "literals embedded between text" do + raw = RawData.new("foo bar {3}\r\nbaz {4+}\r\nquux etc") + assert_equal [ + RawText["foo bar "], + Literal["baz", false], + RawText[" "], + Literal["quux", true], # non-synchronizing + RawText[" etc"], + ], raw.data + imap.send_data raw, tag: "t2" + assert_equal [ + Output.put_string("foo bar "), + Output.send_literal("baz", "t2", non_sync: false), + Output.put_string(" "), + Output.send_literal("quux", "t2", non_sync: true), + Output.put_string(" etc"), + ], imap.output + end + + test "empty literals" do + raw = RawData.new("{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n") + assert_equal [ + Literal["", false], + Literal["", true], + Literal8["", false], + Literal8["", true], + ], raw.data + imap.send_data raw, tag: "t2.2" + assert_equal [ + Output.send_literal("", "t2.2", non_sync: false), + Output.send_literal("", "t2.2", non_sync: true), + Output.send_binary_literal("", "t2.2", non_sync: false), + Output.send_binary_literal("", "t2.2", non_sync: true), + ], imap.output + end + + test "raw text embedded between literals" do + raw = RawData.new("{3}\r\nfoo bar") + assert_equal [ + Literal["foo", false], + RawText[" bar"] + ], raw.data + imap.send_data raw, tag: "t3" + assert_equal [ + Output.send_literal("foo", "t3", non_sync: false), + Output.put_string(" bar"), + ], imap.output + end + + test "raw text followed by literal" do + raw = RawData.new("foo {3}\r\nbar") + assert_equal [ + RawText["foo "], + Literal["bar", false], + ], raw.data + imap.send_data raw, tag: "t4" + assert_equal [ + Output.put_string("foo "), + Output.send_literal("bar", "t4", non_sync: false), + ], imap.output + imap.clear + end + + test "binary literal with regular literal" do + raw = RawData.new("foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux") + assert_equal [ + RawText["foo "], + Literal8["\0bar\r\nb", false], + RawText["az "], + Literal["quux", false], + ], raw.data + imap.send_data raw, tag: "t5" + assert_equal [ + Output.put_string("foo "), + Output.send_binary_literal("\0bar\r\nb", "t5", non_sync: false), + Output.put_string("az "), + Output.send_literal("quux", "t5", non_sync: false), + ], imap.output + end + + data( + "CR" => "with \r byte", + "LF" => "with \n byte", + "NULL" => "with \0 byte", + "CRLF" => "with \r\n bytes", + ) + test "invalid bytes in raw text" do |data| + assert_raise_with_message(DataFormatError, /must be.* literal encoded/i) do + RawData.new(data:) + end + end + + test "invalid literal" do |data| + assert_raise_with_message(DataFormatError, /too few bytes/i) do + RawData.new(data: "invalid literal {123}\r\ntoo small") + end + + assert_raise_with_message(DataFormatError, /NULL byte.*in.*literal/i) do + RawData.new(data: "invalid literal {10}\r\ncontains \0 null") + end + end + + test "invalid literal ending ('{123}')" do + assert_raise(DataFormatError) do RawData.new(data: "literal {123}") end + assert_raise(DataFormatError) do RawData.new(data: "literal+ {123+}") end + assert_raise(DataFormatError) do RawData.new(data: "~literal ~{123}") end + assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{123+}") end + raw = RawData.new(data: " {123} ") + assert_equal [RawText[" {123} "]], raw.data + end + end + end
test/net/imap/test_imap_quota.rb+37 −0 added@@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require_relative "fake_server" + +class IMAPQuotaTest < Net::IMAP::TestCase + include Net::IMAP::FakeServer::TestHelper + + test "#setquota(quota_root, limit)" do + with_fake_server do |server, imap| + server.on "SETQUOTA", &:done_ok + + # integer arg + imap.setquota "INBOX", 512 + rcvd_cmd = server.commands.pop + assert_equal "SETQUOTA", rcvd_cmd.name + assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args + + # string arg + imap.setquota "INBOX", "512" + rcvd_cmd = server.commands.pop + assert_equal "SETQUOTA", rcvd_cmd.name + assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args + + # empty quota root, null limit + imap.setquota "", nil + rcvd_cmd = server.commands.pop + assert_equal "SETQUOTA", rcvd_cmd.name + assert_equal '"" ()', rcvd_cmd.args + + assert_raise_with_message(ArgumentError, /invalid value for Integer/) do + imap.setquota "INBOX", "512 620" + end + end + end +end
test/net/imap/test_imap.rb+57 −0 modified@@ -650,6 +650,63 @@ def test_send_invalid_number end end + def test_send_symbol_as_flag + with_fake_server do |server, imap| + server.on "TEST", &:done_ok + + imap.__send__(:send_command, "TEST", :Seen, :Flagged) + assert_equal "\\Seen \\Flagged", server.commands.pop.args + + # symbol may not contain atom-specials + [ + :"with_parens()", + :"with_list_wildcards*", + :"with_list_wildcards%", + :"with_resp_special]", + :"with\0null", + :"with\x7fcontrol_char", + :'"with_quoted_specials"', + :"with_quoted_specials\\", + :"with\rCR", + :"with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do + imap.__send__(:send_command, "TEST", symbol) + end + assert_empty server.commands + end + end + end + + def test_raw_data + with_fake_server do |server, imap| + server.on "TEST", &:done_ok + + imap.__send__(:send_command, "TEST", Net::IMAP::RawData.new("foo bar")) + assert_equal "foo bar", server.commands.pop.args + + imap.__send__(:send_command, "TEST", + Net::IMAP::RawData.new("{3}\r\nfoo"), + Net::IMAP::RawData.new("~{4}\r\n\0bar")) + assert_equal "{3}\r\nfoo ~{4}\r\n\0bar", server.commands.pop.args + + # RawData must pass basic validation before sending command + [ + "with \0 NULL", + "with \r CR", + "with \n LF", + "with \r\n CRLF", + "{1234}\r\nliteral is too small", + "{1}\r\n\0 literal contains NULL", + ].each do |data| + assert_raise(Net::IMAP::DataFormatError) do + imap.__send__(:send_command, "TEST", Net::IMAP::RawData[data:]) + end + assert_empty server.commands + end + end + end + test("send PartialRange args") do with_fake_server do |server, imap| server.on "TEST", &:done_ok
test/net/imap/test_imap_store.rb+2 −2 modified@@ -72,9 +72,9 @@ class IMAPStoreTest < Net::IMAP::TestCase test "#uid_store with changedsince" do with_fake_server select: "inbox" do |server, imap| server.on("UID STORE", &:done_ok) - imap.uid_store 1..-1, "FLAGS", %i[Deleted], unchangedsince: 987 + imap.uid_store 1..-1, "+FLAGS.SILENT", %i[Deleted], unchangedsince: 987 assert_equal( - "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) FLAGS (\\Deleted)", + "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) +FLAGS.SILENT (\\Deleted)", server.commands.pop.raw.strip ) end
9db3e9d60bfb🥅 Strictly validate symbol (\flag) arguments
3 files changed · +117 −6
lib/net/imap/command_data.rb+27 −6 modified@@ -27,6 +27,7 @@ def validate_data(data) end when Time, Date, DateTime when Symbol + Flag.validate(data) else data.validate end @@ -47,7 +48,7 @@ def send_data(data, tag = nil) when Date send_date_data(data) when Symbol - send_symbol_data(data) + Flag[data].send_data(self, tag) else data.send_data(self, tag) end @@ -138,11 +139,13 @@ def send_list_data(list, tag = nil) def send_date_data(date) put_string Net::IMAP.encode_date(date) end def send_time_data(time) put_string Net::IMAP.encode_time(time) end - def send_symbol_data(symbol) - put_string("\\" + symbol.to_s) - end - CommandData = Data.define(:data) do # :nodoc: + def self.validate(...) + data = new(...) + data.validate + data + end + def send_data(imap, tag) raise NoMethodError, "#{self.class} must implement #{__method__}" end @@ -158,8 +161,26 @@ def send_data(imap, tag) end class Atom < CommandData # :nodoc: + def initialize(**) + super + validate + end + + def validate + data.to_s.ascii_only? \ + or raise DataFormatError, "#{self.class} must be ASCII only" + data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \ + and raise DataFormatError, "#{self.class} must not contain atom-specials" + end + def send_data(imap, tag) - imap.__send__(:put_string, data) + imap.__send__(:put_string, data.to_s) + end + end + + class Flag < Atom # :nodoc: + def send_data(imap, tag) + imap.__send__(:put_string, "\\#{data}") end end
test/net/imap/test_command_data.rb+62 −0 modified@@ -6,6 +6,8 @@ class CommandDataTest < Net::IMAP::TestCase DataFormatError = Net::IMAP::DataFormatError + Atom = Net::IMAP::Atom + Flag = Net::IMAP::Flag Literal = Net::IMAP::Literal Literal8 = Net::IMAP::Literal8 @@ -60,6 +62,66 @@ def send_data(*data, tag: TAG) @imap = FakeCommandWriter.new end + test "Atom" do + imap.send_data(Atom[:INBOX], Atom["INBOX"], Atom["etc"]) + assert_equal [ + Output.put_string("INBOX"), + Output.put_string("INBOX"), + Output.put_string("etc"), + ], imap.output + + imap.clear + # atom may not contain atom-specials + [ + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\0null", + "with\x7fcontrol_char", + '"with_quoted_specials"', + "with_quoted_specials\\", + "with\rCR", + "with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\batom\b/i) do + imap.send_data Atom[symbol] + end + end + assert_empty imap.output + end + + test "Flag" do + imap.send_data(Flag[:Seen], Flag[:Flagged], + Flag["Deleted"], Flag["Answered"]) + assert_equal [ + Output.put_string("\\Seen"), + Output.put_string("\\Flagged"), + Output.put_string("\\Deleted"), + Output.put_string("\\Answered"), + ], imap.output + + imap.clear + # symbol may not contain atom-specials + [ + :"with_parens()", + :"with_list_wildcards*", + :"with_list_wildcards%", + :"with_resp_special]", + :"with\0null", + :"with\x7fcontrol_char", + :'"with_quoted_specials"', + :"with_quoted_specials\\", + :"with\rCR", + :"with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do + imap.send_data Flag[symbol] + end + end + assert_empty imap.output + end + test "Literal" do imap.send_data Literal["foo\r\nbar"] imap.send_data Literal["foo\r\nbar", false]
test/net/imap/test_imap.rb+28 −0 modified@@ -622,6 +622,34 @@ def test_send_invalid_number end end + def test_send_symbol_as_flag + with_fake_server do |server, imap| + server.on "TEST", &:done_ok + + imap.__send__(:send_command, "TEST", :Seen, :Flagged) + assert_equal "\\Seen \\Flagged", server.commands.pop.args + + # symbol may not contain atom-specials + [ + :"with_parens()", + :"with_list_wildcards*", + :"with_list_wildcards%", + :"with_resp_special]", + :"with\0null", + :"with\x7fcontrol_char", + :'"with_quoted_specials"', + :"with_quoted_specials\\", + :"with\rCR", + :"with\nLF", + ].each do |symbol| + assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do + imap.__send__(:send_command, "TEST", symbol) + end + assert_empty server.commands + end + end + end + test("send PartialRange args") do with_fake_server do |server, imap| server.on "TEST", &:done_ok
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-75xq-5h9v-w6pxghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-42258ghsaADVISORY
- github.com/ruby/net-imap/commit/6bf02aef7e0b5931010c36e377f79a71636b306bghsaWEB
- github.com/ruby/net-imap/commit/9db3e9d60bfb8f3735ea95015bf8a700f4af9cbbghsaWEB
- github.com/ruby/net-imap/commit/aec06996eb87a7e1bbcef1f9f8926e8add2b8c71ghsaWEB
- github.com/ruby/net-imap/releases/tag/v0.4.24nvdWEB
- github.com/ruby/net-imap/releases/tag/v0.5.14nvdWEB
- github.com/ruby/net-imap/releases/tag/v0.6.4nvdWEB
- github.com/ruby/net-imap/security/advisories/GHSA-75xq-5h9v-w6pxnvdWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/net-imap/CVE-2026-42258.ymlghsaWEB
News mentions
1- Patch Tuesday - May 2026Rapid7 Blog · May 13, 2026