Skip to content

Commit 0ae8576

Browse files
committed
✨ Limit max response size to 512MiB (hard-coded)
_Please note:_ this only limits the size per response. It does _not_ limit how many unhandled responses may be stored on the responses hash.
1 parent b32b675 commit 0ae8576

File tree

4 files changed

+125
-2
lines changed

4 files changed

+125
-2
lines changed

lib/net/imap/errors.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,39 @@ def initialize(msg = "Remote server has disabled the LOGIN command", ...)
1717
class DataFormatError < Error
1818
end
1919

20+
# Error raised when the socket cannot be read, due to a Config limit.
21+
class ResponseReadError < Error
22+
end
23+
24+
# Error raised when a response is larger than IMAP#max_response_size.
25+
class ResponseTooLargeError < ResponseReadError
26+
attr_reader :bytes_read, :literal_size
27+
attr_reader :max_response_size
28+
29+
def initialize(msg = nil, *args,
30+
bytes_read: nil,
31+
literal_size: nil,
32+
max_response_size: nil,
33+
**kwargs)
34+
@bytes_read = bytes_read
35+
@literal_size = literal_size
36+
@max_response_size = max_response_size
37+
msg ||= [
38+
"Response size", response_size_msg, "exceeds max_response_size",
39+
max_response_size && "(#{max_response_size}B)",
40+
].compact.join(" ")
41+
super(msg, *args, **kwargs)
42+
end
43+
44+
private
45+
46+
def response_size_msg
47+
if bytes_read && literal_size
48+
"(#{bytes_read}B read + #{literal_size}B literal)"
49+
end
50+
end
51+
end
52+
2053
# Error raised when a response from the server is non-parsable.
2154
class ResponseParseError < Error
2255
end

lib/net/imap/response_reader.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,46 @@ def read_response_buffer
2828

2929
attr_reader :buff, :literal_size
3030

31+
def bytes_read = buff.bytesize
32+
def empty? = buff.empty?
33+
def done? = line_done? && !get_literal_size
34+
def line_done? = buff.end_with?(CRLF)
3135
def get_literal_size = /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i
3236

3337
def read_line
34-
buff << (@sock.gets(CRLF) or throw :eof)
38+
buff << (@sock.gets(CRLF, read_limit) or throw :eof)
39+
max_response_remaining! unless line_done?
3540
end
3641

3742
def read_literal
43+
# check before allocating memory for literal
44+
max_response_remaining!
3845
literal = String.new(capacity: literal_size)
39-
buff << (@sock.read(literal_size, literal) or throw :eof)
46+
buff << (@sock.read(read_limit(literal_size), literal) or throw :eof)
4047
ensure
4148
@literal_size = nil
4249
end
4350

51+
def read_limit(limit = nil)
52+
[limit, max_response_remaining!].compact.min
53+
end
54+
55+
def max_response_size = 512 << 20 # TODO: Config#max_response_size
56+
def max_response_remaining = max_response_size &.- bytes_read
57+
def response_too_large? = max_response_size &.< min_response_size
58+
def min_response_size = bytes_read + min_response_remaining
59+
60+
def min_response_remaining
61+
empty? ? 3 : done? ? 0 : (literal_size || 0) + 2
62+
end
63+
64+
def max_response_remaining!
65+
return max_response_remaining unless response_too_large?
66+
raise ResponseTooLargeError.new(
67+
max_response_size:, bytes_read:, literal_size:,
68+
)
69+
end
70+
4471
end
4572
end
4673
end

test/net/imap/test_errors.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
require "test/unit"
5+
6+
class IMAPErrorsTest < Test::Unit::TestCase
7+
8+
test "ResponseTooLargeError" do
9+
err = Net::IMAP::ResponseTooLargeError.new
10+
assert_nil err.bytes_read
11+
assert_nil err.literal_size
12+
assert_nil err.max_response_size
13+
14+
err = Net::IMAP::ResponseTooLargeError.new("manually set message")
15+
assert_equal "manually set message", err.message
16+
assert_nil err.bytes_read
17+
assert_nil err.literal_size
18+
assert_nil err.max_response_size
19+
20+
err = Net::IMAP::ResponseTooLargeError.new(max_response_size: 1024)
21+
assert_equal "Response size exceeds max_response_size (1024B)", err.message
22+
assert_nil err.bytes_read
23+
assert_nil err.literal_size
24+
assert_equal 1024, err.max_response_size
25+
26+
err = Net::IMAP::ResponseTooLargeError.new(bytes_read: 1200,
27+
max_response_size: 1200)
28+
assert_equal 1200, err.bytes_read
29+
assert_equal "Response size exceeds max_response_size (1200B)", err.message
30+
31+
err = Net::IMAP::ResponseTooLargeError.new(bytes_read: 800,
32+
literal_size: 1000,
33+
max_response_size: 1200)
34+
assert_equal 800, err.bytes_read
35+
assert_equal 1000, err.literal_size
36+
assert_equal("Response size (800B read + 1000B literal) " \
37+
"exceeds max_response_size (1200B)", err.message)
38+
end
39+
40+
end

test/net/imap/test_response_reader.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,27 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
4949
assert_equal "", rcvr.read_response_buffer.to_str
5050
end
5151

52+
class LimitedResponseReader < Net::IMAP::ResponseReader
53+
attr_reader :max_response_size
54+
def initialize(*args, max_response_size:)
55+
super(*args)
56+
@max_response_size = max_response_size
57+
end
58+
end
59+
60+
test "#read_response_buffer with max_response_size" do
61+
client = FakeClient.new
62+
max_response_size = 10
63+
under = "+ 3456\r\n"
64+
exact = "+ 345678\r\n"
65+
over = "+ 3456789\r\n"
66+
io = StringIO.new([under, exact, over].join)
67+
rcvr = LimitedResponseReader.new(client, io, max_response_size:)
68+
assert_equal under, rcvr.read_response_buffer.to_str
69+
assert_equal exact, rcvr.read_response_buffer.to_str
70+
assert_raise Net::IMAP::ResponseTooLargeError do
71+
rcvr.read_response_buffer
72+
end
73+
end
74+
5275
end

0 commit comments

Comments
 (0)