Skip to content

Commit 6bf02ae

Browse files
authored
🔀 Merge pull request #662 from ruby/backport/v0.5/raw_data-warnings
🔒 Fix CRLF injection vulnerabilities (backports #657, #658, #659, #660, #636, #661)
2 parents 808001b + fa478c5 commit 6bf02ae

7 files changed

Lines changed: 572 additions & 45 deletions

File tree

lib/net/imap.rb

Lines changed: 84 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,9 @@ module Net
462462
# +LITERAL-+, and +SPECIAL-USE+.</em>
463463
#
464464
# ==== RFC2087: +QUOTA+
465+
# +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
466+
# - Obsoleted by <tt>QUOTA=RES-*</tt> [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]],
467+
# although the commands are backward compatible.
465468
# - #getquota: returns the resource usage and limits for a quota root
466469
# - #getquotaroot: returns the list of quota roots for a mailbox, as well as
467470
# their resource usage and limits.
@@ -578,6 +581,16 @@ module Net
578581
# See FetchData#emailid and FetchData#emailid.
579582
# - Updates #status with support for the +MAILBOXID+ status attribute.
580583
#
584+
# ==== RFC9208: <tt>QUOTA=RES-*</tt>
585+
# +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
586+
# - Obsoletes the +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
587+
# extension and provides strict semantics for different resource types.
588+
# - #getquota: returns the resource usage and limits for a quota root
589+
# - #getquotaroot: returns the list of quota roots for a mailbox, as well as
590+
# their resource usage and limits.
591+
# - #setquota: sets the resource limits for a given quota root.
592+
# - Updates #status with <tt>"DELETED"</tt> and +DELETED-STORAGE+ attributes.
593+
#
581594
# ==== RFC9394: +PARTIAL+
582595
# - Updates #search, #uid_search with the +PARTIAL+ return option which adds
583596
# ESearchResult#partial return data.
@@ -698,13 +711,12 @@ module Net
698711
#
699712
# === \IMAP Extensions
700713
#
701-
# [QUOTA[https://www.rfc-editor.org/rfc/rfc9208]]::
702-
# Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208,
703-
# March 2022, <https://www.rfc-editor.org/info/rfc9208>.
714+
# [QUOTA[https://www.rfc-editor.org/rfc/rfc2087]]::
715+
# Myers, J., "IMAP4 QUOTA extension", RFC 2087, DOI 10.17487/RFC2087,
716+
# January 1997, <https://www.rfc-editor.org/info/rfc2087>.
704717
#
705-
# <em>Note: obsoletes</em>
706-
# RFC-2087[https://www.rfc-editor.org/rfc/rfc2087]<em> (January 1997)</em>.
707-
# <em>Net::IMAP does not fully support the RFC9208 updates yet.</em>
718+
# *NOTE*: _obsoleted_ by RFC9208[https://www.rfc-editor.org/rfc/rfc9208]
719+
# (March 2022).
708720
# [IDLE[https://www.rfc-editor.org/rfc/rfc2177]]::
709721
# Leiba, B., "IMAP4 IDLE command", RFC 2177, DOI 10.17487/RFC2177,
710722
# June 1997, <https://www.rfc-editor.org/info/rfc2177>.
@@ -756,6 +768,11 @@ module Net
756768
# Gondwana, B., Ed., "IMAP Extension for Object Identifiers",
757769
# RFC 8474, DOI 10.17487/RFC8474, September 2018,
758770
# <https://www.rfc-editor.org/info/rfc8474>.
771+
# [{QUOTA=RES-*}[https://www.rfc-editor.org/rfc/rfc9208]]::
772+
# Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208,
773+
# March 2022, <https://www.rfc-editor.org/info/rfc9208>.
774+
#
775+
# Obsoletes RFC2087[https://www.rfc-editor.org/rfc/rfc2087].
759776
# [PARTIAL[https://www.rfc-editor.org/info/rfc9394]]::
760777
# Melnikov, A., Achuthan, A., Nagulakonda, V., and L. Alves,
761778
# "IMAP PARTIAL Extension for Paged SEARCH and FETCH", RFC 9394,
@@ -769,6 +786,7 @@ module Net
769786
#
770787
# === IANA registries
771788
# * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities]
789+
# * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2]
772790
# * {IMAP Response Codes}[https://www.iana.org/assignments/imap-response-codes/imap-response-codes.xhtml]
773791
# * {IMAP Mailbox Name Attributes}[https://www.iana.org/assignments/imap-mailbox-name-attributes/imap-mailbox-name-attributes.xhtml]
774792
# * {IMAP and JMAP Keywords}[https://www.iana.org/assignments/imap-jmap-keywords/imap-jmap-keywords.xhtml]
@@ -779,8 +797,8 @@ module Net
779797
# * {GSSAPI/Kerberos/SASL Service Names}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml]:
780798
# +imap+
781799
# * {Character sets}[https://www.iana.org/assignments/character-sets/character-sets.xhtml]
800+
#
782801
# ==== For currently unsupported features:
783-
# * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2]
784802
# * {LIST-EXTENDED options and responses}[https://www.iana.org/assignments/imap-list-extended/imap-list-extended.xhtml]
785803
# * {IMAP METADATA Server Entry and Mailbox Entry Registries}[https://www.iana.org/assignments/imap-metadata/imap-metadata.xhtml]
786804
# * {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)
18281846
# to both admin and user. If this mailbox exists, it returns an array
18291847
# containing objects of type MailboxQuotaRoot and MailboxQuota.
18301848
#
1849+
# *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single
1850+
# resource type. This is usually +STORAGE+, but you may need to verify this
1851+
# with UntaggedResponse#raw_data.
1852+
#
18311853
# Related: #getquota, #setquota, MailboxQuotaRoot, MailboxQuota
18321854
#
18331855
# ==== Capabilities
18341856
#
1835-
# The server's capabilities must include +QUOTA+
1836-
# [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]].
1857+
# Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
1858+
# capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
1859+
# {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
1860+
# resource type.
18371861
def getquotaroot(mailbox)
18381862
synchronize do
18391863
send_command("GETQUOTAROOT", mailbox)
@@ -1845,41 +1869,59 @@ def getquotaroot(mailbox)
18451869
end
18461870

18471871
# Sends a {GETQUOTA command [RFC2087 §4.2]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.2]
1848-
# along with specified +mailbox+. If this mailbox exists, then an array
1849-
# containing a MailboxQuota object is returned. This command is generally
1850-
# only available to server admin.
1872+
# for the +quota_root+. If this quota root exists, then an array
1873+
# containing a MailboxQuota object is returned.
1874+
#
1875+
# The names of quota roots that are applicable to a particular mailbox can
1876+
# be discovered with #getquotaroot.
1877+
#
1878+
# *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single
1879+
# resource type. This is usually +STORAGE+, but you may need to verify this
1880+
# with UntaggedResponse#raw_data.
18511881
#
18521882
# Related: #getquotaroot, #setquota, MailboxQuota
18531883
#
18541884
# ==== Capabilities
18551885
#
1856-
# The server's capabilities must include +QUOTA+
1857-
# [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]].
1858-
def getquota(mailbox)
1886+
# Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
1887+
# capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
1888+
# {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
1889+
# resource type.
1890+
def getquota(quota_root)
18591891
synchronize do
1860-
send_command("GETQUOTA", mailbox)
1892+
send_command("GETQUOTA", quota_root)
18611893
clear_responses("QUOTA")
18621894
end
18631895
end
18641896

18651897
# Sends a {SETQUOTA command [RFC2087 §4.1]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.1]
1866-
# along with the specified +mailbox+ and +quota+. If +quota+ is nil, then
1867-
# +quota+ will be unset for that mailbox. Typically one needs to be logged
1868-
# in as a server admin for this to work.
1898+
# along with the specified +quota_root+ and +storage_limit+. If
1899+
# +storage_limit+ is +nil+, resource limits are unset for that quota root.
1900+
# If +storage_limit+ is a number, it sets the +STORAGE+ resource limit.
1901+
#
1902+
# imap.setquota "#user/alice", 100
1903+
# imap.getquota "#user/alice"
1904+
# # => [#<struct Net::IMAP::MailboxQuota mailbox="#user/alice" usage=54 quota=100>]
1905+
#
1906+
# Typically one needs to be logged in as a server admin for this to work.
1907+
#
1908+
# *NOTE:* Currently, Net::IMAP only supports setting +STORAGE+ quota limits.
18691909
#
18701910
# Related: #getquota, #getquotaroot
18711911
#
18721912
# ==== Capabilities
18731913
#
1874-
# The server's capabilities must include +QUOTA+
1875-
# [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]].
1876-
def setquota(mailbox, quota)
1877-
if quota.nil?
1878-
data = '()'
1914+
# Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
1915+
# capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
1916+
# {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
1917+
# resource type.
1918+
def setquota(quota_root, storage_limit)
1919+
if storage_limit.nil?
1920+
list = []
18791921
else
1880-
data = '(STORAGE ' + quota.to_s + ')'
1922+
list = ["STORAGE", Integer(storage_limit)]
18811923
end
1882-
send_command("SETQUOTA", mailbox, RawData.new(data))
1924+
send_command("SETQUOTA", quota_root, list)
18831925
end
18841926

18851927
# 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)
19862028
# <tt>STATUS=SIZE</tt>
19872029
# {[RFC8483]}[https://www.rfc-editor.org/rfc/rfc8483.html].
19882030
#
1989-
# +DELETED+ requires the server's capabilities to include +IMAP4rev2+.
2031+
# +DELETED+ must be supported when the server's capabilities includes
2032+
# +IMAP4rev2+.
2033+
# or <tt>QUOTA=RES-MESSAGES</tt>
2034+
# {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html].
19902035
#
19912036
# +HIGHESTMODSEQ+ requires the server's capabilities to include +CONDSTORE+
19922037
# {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html].
@@ -2267,11 +2312,11 @@ def uid_expunge(uid_set)
22672312
# Encoded as an \IMAP date (see ::encode_date).
22682313
#
22692314
# [When +criteria+ is a String]
2270-
# +criteria+ will be sent directly to the server <em>without any
2271-
# validation or encoding</em>.
2315+
# +criteria+ will be sent to the server <em>with minimal validation and no
2316+
# encoding or formatting</em>.
22722317
#
2273-
# <em>*WARNING:* This is vulnerable to injection attacks when external
2274-
# inputs are used.</em>
2318+
# <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other
2319+
# types of attribute injection attack if unvetted user input is used.</em>
22752320
#
22762321
# ==== Supported return options
22772322
#
@@ -2592,6 +2637,13 @@ def uid_search(...)
25922637
#
25932638
# +attr+ is a list of attributes to fetch; see FetchStruct documentation for
25942639
# a list of supported attributes.
2640+
# >>>
2641+
# When +attr+ is a String, it will be sent <em>with minimal validation and
2642+
# no encoding or formatting</em>. When +attr+ is an Array, each String in
2643+
# +attr+ will be sent this way.
2644+
#
2645+
# <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other
2646+
# types of attribute injection attack if unvetted user input is used.</em>
25952647
#
25962648
# +changedsince+ is an optional integer mod-sequence. It limits results to
25972649
# messages with a mod-sequence greater than +changedsince+.
@@ -3712,7 +3764,7 @@ def fetch_internal(cmd, set, attr, mod = nil, partial: nil, changedsince: nil)
37123764
end
37133765

37143766
def store_internal(cmd, set, attr, flags, unchangedsince: nil)
3715-
attr = RawData.new(attr) if attr.instance_of?(String)
3767+
attr = Atom.new(attr) if attr.instance_of?(String)
37163768
args = [SequenceSet.new(set)]
37173769
args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince
37183770
args << attr << flags

lib/net/imap/command_data.rb

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def validate_data(data)
2828
end
2929
when Time, Date, DateTime
3030
when Symbol
31+
Flag.validate(data)
3132
else
3233
data.validate
3334
end
@@ -48,7 +49,7 @@ def send_data(data, tag = nil)
4849
when Date
4950
send_date_data(data)
5051
when Symbol
51-
send_symbol_data(data)
52+
Flag[data].send_data(self, tag)
5253
else
5354
data.send_data(self, tag)
5455
end
@@ -132,11 +133,13 @@ def send_list_data(list, tag = nil)
132133
def send_date_data(date) put_string Net::IMAP.encode_date(date) end
133134
def send_time_data(time) put_string Net::IMAP.encode_time(time) end
134135

135-
def send_symbol_data(symbol)
136-
put_string("\\" + symbol.to_s)
137-
end
138-
139136
CommandData = Data.define(:data) do # :nodoc:
137+
def self.validate(...)
138+
data = new(...)
139+
data.validate
140+
data
141+
end
142+
140143
def send_data(imap, tag)
141144
raise NoMethodError, "#{self.class} must implement #{__method__}"
142145
end
@@ -145,15 +148,109 @@ def validate
145148
end
146149
end
147150

151+
# Represents IMAP +text+ data, which may contain any 7-bit ASCII character,
152+
# except for +NULL+, +CR+, or +LF+. +text+ is extended to allow any
153+
# multibyte +UTF-8+ character when either +UTF8=ACCEPT+ or +IMAP4rev2+ have
154+
# been enabled, or when the server supports only +IMAP4rev2+ and not earlier
155+
# IMAP revisions, or when the server advertises +UTF8=ONLY+.
156+
#
157+
# NOTE: The current implementation does not validate whether the connection
158+
# currently supports UTF-8. Future versions may change.
159+
#
160+
# The string's bytes must be valid ASCII or valid UTF-8. The string's
161+
# reported encoding is ignored, but the string is _not_ transcoded.
162+
class RawText < CommandData # :nodoc:
163+
def initialize(data:)
164+
data = String(data.to_str)
165+
data = if data.encoding in Encoding::ASCII | Encoding::UTF_8
166+
-data
167+
elsif data.ascii_only?
168+
-(data.dup.force_encoding("ASCII"))
169+
else
170+
-(data.dup.force_encoding("UTF-8"))
171+
end
172+
super
173+
validate
174+
end
175+
176+
def validate
177+
if data.include?("\0")
178+
raise DataFormatError, "NULL byte must be binary literal encoded"
179+
elsif !data.valid_encoding?
180+
raise DataFormatError, "invalid UTF-8 must be literal encoded"
181+
elsif /[\r\n]/.match?(data)
182+
raise DataFormatError, "CR and LF bytes must be literal encoded"
183+
end
184+
end
185+
186+
def ascii_only? = data.ascii_only?
187+
188+
def send_data(imap, tag) = imap.__send__(:put_string, data)
189+
end
190+
148191
class RawData < CommandData # :nodoc:
149-
def send_data(imap, tag)
150-
imap.__send__(:put_string, data)
192+
def initialize(data:)
193+
data = split_parts(data)
194+
super
195+
validate
196+
end
197+
198+
def send_data(imap, tag) = data.each do _1.send_data(imap, tag) end
199+
200+
def validate
201+
return unless data.last in RawText(data: text)
202+
if text.rindex(/~?\{[1-9]\d*\+?\}\z/n)
203+
raise DataFormatError, "RawData cannot end with literal continuation"
204+
end
205+
end
206+
207+
private
208+
209+
def split_parts(data)
210+
data = data.b # dups and ensures BINARY encoding
211+
parts = []
212+
while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
213+
text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $'
214+
bytesize = Integer bytesize, 10
215+
parts << RawText[text] unless text.empty?
216+
parts << extract_literal(data, binary:, bytesize:, non_sync:)
217+
data[0, bytesize] = ""
218+
end
219+
parts << RawText[data] unless data.empty?
220+
parts
221+
end
222+
223+
def extract_literal(data, binary:, bytesize:, non_sync:)
224+
if data.bytesize < bytesize
225+
raise DataFormatError, "Too few bytes in string for literal, " \
226+
"expected: %s, remaining: %s" % [bytesize, data.bytesize]
227+
end
228+
literal = data.byteslice(0, bytesize)
229+
(binary ? Literal8 : Literal).new(data: literal, non_sync:)
151230
end
152231
end
153232

154233
class Atom < CommandData # :nodoc:
234+
def initialize(**)
235+
super
236+
validate
237+
end
238+
239+
def validate
240+
data.to_s.ascii_only? \
241+
or raise DataFormatError, "#{self.class} must be ASCII only"
242+
data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \
243+
and raise DataFormatError, "#{self.class} must not contain atom-specials"
244+
end
245+
246+
def send_data(imap, tag)
247+
imap.__send__(:put_string, data.to_s)
248+
end
249+
end
250+
251+
class Flag < Atom # :nodoc:
155252
def send_data(imap, tag)
156-
imap.__send__(:put_string, data)
253+
imap.__send__(:put_string, "\\#{data}")
157254
end
158255
end
159256

0 commit comments

Comments
 (0)