Skip to content

Commit fe29bad

Browse files
authored
Reject NUL, CR and LF in _raw_command arguments before sending (#658)
CR and LF end the command line on the wire, so an argument carrying them, for example a search criterion built from a sender-controlled header, is run by the server as extra commands. _raw_command bypasses imaplib._command, so CPython's check for the same characters does not apply here. Check every argument before the first send, so a bad one never leaves a half-sent command; 8-bit values still go as literals, which may carry CR and LF. Fixes #657 `literal` is now exported so callers can wrap a value that carries CR or LF, as the new error message suggests. _literal stays as an alias for code that imported the old name. search() quoted any value with a space, so a literal holding CR or LF came out as a _quoted string and the new check rejected it, even though the error message says to wrap the value in literal.
1 parent 4090d8e commit fe29bad

3 files changed

Lines changed: 71 additions & 12 deletions

File tree

‎imapclient/imapclient.py‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"FLAGGED",
4343
"DRAFT",
4444
"RECENT",
45+
"literal",
4546
]
4647

4748

@@ -1461,9 +1462,9 @@ def chunks():
14611462
yield to_bytes(seq_to_parenstr(m["flags"]))
14621463
if "date" in m:
14631464
yield to_bytes('"%s"' % datetime_to_INTERNALDATE(m["date"]))
1464-
yield _literal(to_bytes(m["msg"]))
1465+
yield literal(to_bytes(m["msg"]))
14651466
else:
1466-
yield _literal(to_bytes(m))
1467+
yield literal(to_bytes(m))
14671468

14681469
msgs = list(chunks())
14691470

@@ -1709,11 +1710,25 @@ def _raw_command(self, command, args, uid=True):
17091710
prefix.append(b"UID")
17101711
prefix.append(command)
17111712

1712-
line = []
1713-
for item, is_last in _iter_with_last(prefix + args):
1713+
# Check every argument before anything goes on the wire: a bad
1714+
# argument found after a literal was sent would leave the server
1715+
# waiting inside a half-sent command.
1716+
for item in itertools.chain(prefix, args):
17141717
if not isinstance(item, bytes):
17151718
raise ValueError("command args must be passed as bytes")
1719+
if b"\x00" in item:
1720+
raise ValueError("NUL is not allowed in command arguments")
1721+
if not _is8bit(item) and (b"\r" in item or b"\n" in item):
1722+
# CR and LF end the command line on the wire, so an argument
1723+
# carrying them would be run as extra commands. A literal can
1724+
# hold them, which is what the 8-bit path already sends.
1725+
raise ValueError(
1726+
"CR and LF are not allowed in command arguments; "
1727+
"wrap the value in imapclient.literal to send it as a literal"
1728+
)
17161729

1730+
line = []
1731+
for item, is_last in _iter_with_last(prefix + args):
17171732
if _is8bit(item):
17181733
# If a line was already started send it
17191734
if line:
@@ -1872,6 +1887,8 @@ def _normalise_search_criteria(criteria, charset=None):
18721887
inner[0] = b"(" + inner[0]
18731888
inner[-1] = inner[-1] + b")"
18741889
out.extend(inner) # flatten
1890+
elif isinstance(item, literal):
1891+
out.append(item)
18751892
else:
18761893
out.append(_quoted.maybe(to_bytes(item, charset)))
18771894
return out
@@ -1883,10 +1900,13 @@ def _normalise_sort_criteria(criteria, charset=None):
18831900
return b"(" + b" ".join(to_bytes(item).upper() for item in criteria) + b")"
18841901

18851902

1886-
class _literal(bytes):
1903+
class literal(bytes):
18871904
"""Hold message data that should always be sent as a literal."""
18881905

18891906

1907+
_literal = literal # old private name, kept for callers that imported it
1908+
1909+
18901910
class _quoted(bytes):
18911911
"""
18921912
This class holds a quoted bytes value which provides access to the
@@ -1979,7 +1999,7 @@ def as_triplets(items):
19791999

19802000

19812001
def _is8bit(data):
1982-
return isinstance(data, _literal) or any(b > 127 for b in data)
2002+
return isinstance(data, literal) or any(b > 127 for b in data)
19832003

19842004

19852005
def _iter_with_last(items):

‎tests/test_imapclient.py‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
from imapclient.exceptions import CapabilityError, IMAPClientError, ProtocolError
1616
from imapclient.fixed_offset import FixedOffset
1717
from imapclient.imapclient import (
18-
_literal,
1918
_parse_quota,
2019
IMAPlibLoggerAdapter,
20+
literal,
2121
MailboxQuotaRoots,
2222
Quota,
2323
require_capability,
@@ -416,11 +416,11 @@ def test_multiappend_with_flags_and_internaldate(self):
416416
b'"foobar"',
417417
b"(FLAG WAVE)",
418418
b'"05-Apr-2009 11:00:05 +0200"',
419-
_literal(b"msg1"),
419+
literal(b"msg1"),
420420
b"(FLAG WAVE)",
421-
_literal(b"msg2"),
421+
literal(b"msg2"),
422422
b'"05-Apr-2009 11:00:05 +0200"',
423-
_literal(b"msg3"),
423+
literal(b"msg3"),
424424
],
425425
uid=False,
426426
)
@@ -749,6 +749,40 @@ def fake_get_response():
749749
self.assertListEqual([(99, b"EXISTS")], responses)
750750

751751

752+
class TestRawCommandRejectsControlCharacters(IMAPClientTest):
753+
def setUp(self):
754+
super().setUp()
755+
self.client._imap.send = Mock()
756+
self.client._cached_capabilities = (b"IMAP4REV1",)
757+
758+
def test_crlf_in_a_line_argument_is_rejected_before_sending(self):
759+
criterion = b'x"\r\nX1 STORE 1:* +FLAGS (\\Deleted)\r\nX2 EXPUNGE'
760+
761+
with self.assertRaises(ValueError):
762+
self.client._raw_command(b"SEARCH", [b"HEADER", b"Message-ID", criterion])
763+
764+
self.client._imap.send.assert_not_called()
765+
766+
def test_crlf_via_search_criteria_is_rejected_before_sending(self):
767+
with self.assertRaises(ValueError):
768+
self.client.search(["HEADER", "Message-ID", 'x"\r\nX1 NOOP'])
769+
770+
self.client._imap.send.assert_not_called()
771+
772+
def test_nul_is_rejected_even_inside_a_literal(self):
773+
with self.assertRaises(ValueError):
774+
self.client._raw_command(b"SEARCH", [b"TEXT", literal(b"a\x00b")])
775+
776+
self.client._imap.send.assert_not_called()
777+
778+
def test_crlf_inside_a_literal_is_allowed(self):
779+
self.client._send_literal = Mock()
780+
781+
self.client._raw_command(b"SEARCH", [b"TEXT", literal(b"line one\r\nline two")])
782+
783+
self.client._send_literal.assert_called_once()
784+
785+
752786
class TestDebugLogging(IMAPClientTest):
753787
def test_IMAP_is_patched(self):
754788
# Remove all logging handlers so that the order of tests does not
@@ -1006,7 +1040,7 @@ def test_literal_plus(self):
10061040
self.client._cached_capabilities = (b"LITERAL+",)
10071041

10081042
typ, data = self.client._raw_command(
1009-
b"APPEND", [b"\xff", _literal(b"hello")], uid=False
1043+
b"APPEND", [b"\xff", literal(b"hello")], uid=False
10101044
)
10111045
self.assertEqual(typ, "OK")
10121046
self.assertEqual(data, ["done"])
@@ -1020,7 +1054,7 @@ def test_literal_plus_multiple_literals(self):
10201054

10211055
typ, data = self.client._raw_command(
10221056
b"APPEND",
1023-
[b"\xff", _literal(b"hello"), b"TEXT", _literal(b"test")],
1057+
[b"\xff", literal(b"hello"), b"TEXT", literal(b"test")],
10241058
uid=False,
10251059
)
10261060
self.assertEqual(typ, "OK")

‎tests/test_util_functions.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
_normalise_search_criteria,
1010
_quoted,
1111
join_message_ids,
12+
literal,
1213
normalise_text_list,
1314
seq_to_parenstr,
1415
seq_to_parenstr_upper,
@@ -139,6 +140,10 @@ def test_mixed_list(self):
139140
def test_quoting(self):
140141
self.check(["foo bar"], None, [_quoted(b'"foo bar"')])
141142

143+
def test_literal_is_not_quoted(self):
144+
value = literal(b"line one\r\nline two")
145+
self.check(["TEXT", value], None, [b"TEXT", value])
146+
142147
def test_ints(self):
143148
self.check(["modseq", 500], None, [b"modseq", b"500"])
144149

0 commit comments

Comments
 (0)