Skip to content

Commit b6493c5

Browse files
committed
✨ Enable parenthesized lists in search criteria [🚧 WIP: SequenceSet coercion]
This affects search, uid_search, sort, uid_sort, thread, and uid_thread. Prior to this, sending a parenthesized list in the search criteria for any of these commands required the use of strings, which are converted to RawData, which has security implications with untrusted inputs. With this change, arrays will only be converted into SequenceSet when _every_ element in the array is a valid SequenceSet input. Otherwise, the array will be left alone, which allows us to send parenthesized lists without using strings and RawData. For example, some search criteria this change enables: * `["not", %w[flagged unread]]` converts to `not (flagged unread)`. * `["return", ["partial", 1..50]]` converts to `return (partial 1:50)`.
1 parent 8591ec0 commit b6493c5

File tree

3 files changed

+51
-24
lines changed

3 files changed

+51
-24
lines changed

lib/net/imap.rb

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,13 +1944,19 @@ def uid_expunge(uid_set)
19441944
#
19451945
# * When +criteria+ is an array, each member is a +SEARCH+ command argument:
19461946
# * Any SequenceSet sends SequenceSet#valid_string.
1947-
# +Range+, <tt>-1</tt>, and nested +Array+ elements are converted to
1948-
# SequenceSet.
1949-
# * Any +String+ is sent verbatim when it is a valid \IMAP atom,
1947+
# These types are converted to SequenceSet for validation and encoding:
1948+
# * +Set+
1949+
# * +Range+
1950+
# * <tt>-1</tt> and +:*+ -- both translate to <tt>*</tt>
1951+
# * responds to +#to_sequence_set+
1952+
# * +String+, when formatted as a \IMAP sequence-set
1953+
# * deeply nested +Array+, when all members are one of these types.
1954+
# * Any other +String+ is sent verbatim when it is a valid \IMAP atom,
19501955
# and encoded as an \IMAP quoted or literal string otherwise.
1956+
# * Any other nested +Array+ is encoded as a parenthesized list, to group
1957+
# multiple search keys (e.g., for use with +OR+ and +NOT+).
19511958
# * Any other +Integer+ (besides <tt>-1</tt>) will be sent as +#to_s+.
19521959
# * +Date+ objects will be encoded as an \IMAP date (see ::encode_date).
1953-
#
19541960
# * When +criteria+ is a string, it will be sent directly to the server
19551961
# <em>without any validation or encoding</em>. *WARNING:* This is
19561962
# vulnerable to injection attacks when external inputs are used.
@@ -1972,13 +1978,13 @@ def uid_expunge(uid_set)
19721978
# The following searches send the exact same command to the server:
19731979
#
19741980
# # criteria array, charset arg
1975-
# imap.search(%w[OR UNSEEN FLAGGED SUBJECT foo], "UTF-8")
1981+
# imap.search(["OR", "UNSEEN", %w(FLAGGED SUBJECT foo)], "UTF-8")
19761982
# # criteria string, charset arg
1977-
# imap.search("OR UNSEEN FLAGGED SUBJECT foo", "UTF-8")
1983+
# imap.search("OR UNSEEN (FLAGGED SUBJECT foo)", "UTF-8")
19781984
# # criteria array contains charset arg
1979-
# imap.search(%w[CHARSET UTF-8 OR UNSEEN FLAGGED SUBJECT foo])
1985+
# imap.search([*%w[CHARSET UTF-8], "OR", "UNSEEN", %w(FLAGGED SUBJECT foo)])
19801986
# # criteria string contains charset arg
1981-
# imap.search("CHARSET UTF-8 OR UNSEEN FLAGGED SUBJECT foo")
1987+
# imap.search("CHARSET UTF-8 OR UNSEEN (FLAGGED SUBJECT foo)")
19821988
#
19831989
# ===== Search keys
19841990
#
@@ -3191,14 +3197,7 @@ def thread_internal(cmd, algorithm, search_keys, charset)
31913197

31923198
def normalize_searching_criteria(criteria)
31933199
return RawData.new(criteria) if criteria.is_a?(String)
3194-
criteria.map do |i|
3195-
case i
3196-
when -1, Range, Array
3197-
SequenceSet.new(i)
3198-
else
3199-
i
3200-
end
3201-
end
3200+
criteria.map {|i| SequenceSet::Coercible[i] ? SequenceSet[i] : i }
32023201
end
32033202

32043203
def build_ssl_ctx(ssl)

lib/net/imap/sequence_set.rb

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,16 +276,29 @@ class SequenceSet
276276
# The largest possible non-zero unsigned 32-bit integer
277277
UINT32_MAX = 2**32 - 1
278278

279+
REGEXP = ResponseParser::Patterns::SEQUENCE_SET_STR
280+
private_constant :REGEXP
281+
279282
# represents "*" internally, to simplify sorting (etc)
280283
STAR_INT = UINT32_MAX + 1
281284
private_constant :STAR_INT
282285

283286
# valid inputs for "*"
284287
STARS = [:*, ?*, -1].freeze
285-
private_constant :STAR_INT, :STARS
288+
private_constant :STARS
286289

287-
COERCIBLE = ->{ _1.respond_to? :to_sequence_set }
288-
private_constant :COERCIBLE
290+
# Matches objects which should be implicitly converted into SequenceSet
291+
# objects. Note that the inputs are not validated, and some valid inputs
292+
# (Enumerable other than Array or Set) will be rejected.
293+
Coercible = ->(obj) do
294+
case obj
295+
when SequenceSet then true
296+
when Integer, Range, *STARS then true
297+
when String then REGEXP.match?(obj.b)
298+
when Array, Set then obj.all?(Coercible) && !obj.empty?
299+
else obj.respond_to?(:to_sequence_set)
300+
end
301+
end
289302

290303
class << self
291304

test/net/imap/test_imap.rb

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,16 +1206,31 @@ def test_unselect
12061206
end
12071207

12081208
server.on "SEARCH", &search_resp
1209-
assert_equal search_result, imap.search(["subject", "hello",
1210-
[1..5, 8, 10..-1]])
1209+
server.on "UID SEARCH", &search_resp
1210+
1211+
assert_equal search_result, imap.search(
1212+
["subject", "hello", [1..5, 8, 10..-1]]
1213+
)
12111214
cmd = server.commands.pop
12121215
assert_equal ["SEARCH", "subject hello 1:5,8,10:*"], [cmd.name, cmd.args]
12131216

1214-
server.on "UID SEARCH", &search_resp
1215-
assert_equal search_result, imap.uid_search(["subject", "hello",
1216-
[1..22, 30..-1]])
1217+
assert_equal search_result, imap.uid_search(
1218+
["subject", "hello", [1..22, 30..-1]]
1219+
)
12171220
cmd = server.commands.pop
12181221
assert_equal ["UID SEARCH", "subject hello 1:22,30:*"], [cmd.name, cmd.args]
1222+
1223+
assert_equal search_result, imap.search(
1224+
"RETURN (COUNT) NOT (FLAGGED (OR SEEN ANSWERED))"
1225+
)
1226+
cmd = server.commands.pop
1227+
assert_equal "RETURN (COUNT) NOT (FLAGGED (OR SEEN ANSWERED))", cmd.args
1228+
1229+
assert_equal search_result, imap.search([
1230+
"RETURN", %w(MIN MAX COUNT), "NOT", ["FLAGGED", %w(OR SEEN ANSWERED)]
1231+
])
1232+
cmd = server.commands.pop
1233+
assert_equal "RETURN (MIN MAX COUNT) NOT (FLAGGED (OR SEEN ANSWERED))", cmd.args
12191234
end
12201235
end
12211236

0 commit comments

Comments
 (0)