Skip to content

Commit 7d03b0f

Browse files
authored
🔀 Merge pull request #447 from ruby/backport/v0.2-max_response_size
✨ Limit max_response_size (backport 0.2)
2 parents c64edec + 673cab8 commit 7d03b0f

File tree

6 files changed

+275
-2
lines changed

6 files changed

+275
-2
lines changed

‎lib/net/imap.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ module Net
133133
#
134134
# Use paginated or limited versions of commands whenever possible.
135135
#
136+
# Use #max_response_size to impose a limit on incoming server responses
137+
# as they are being read. <em>This is especially important for untrusted
138+
# servers.</em>
139+
#
136140
# Use #add_response_handler to handle responses after each one is received.
137141
# Use the +response_handlers+ argument to ::new to assign response handlers
138142
# before the receiver thread is started.
@@ -284,6 +288,40 @@ class IMAP < Protocol
284288
# Seconds to wait until an IDLE response is received.
285289
attr_reader :idle_response_timeout
286290

291+
# The maximum allowed server response size. When +nil+, there is no limit
292+
# on response size.
293+
#
294+
# The default value is _unlimited_ (after +v0.5.8+, the default is 512 MiB).
295+
# A _much_ lower value should be used with untrusted servers (for example,
296+
# when connecting to a user-provided hostname). When using a lower limit,
297+
# message bodies should be fetched in chunks rather than all at once.
298+
#
299+
# <em>Please Note:</em> this only limits the size per response. It does
300+
# not prevent a flood of individual responses and it does not limit how
301+
# many unhandled responses may be stored on the responses hash. See
302+
# Net::IMAP@Unbounded+memory+use.
303+
#
304+
# Socket reads are limited to the maximum remaining bytes for the current
305+
# response: max_response_size minus the bytes that have already been read.
306+
# When the limit is reached, or reading a +literal+ _would_ go over the
307+
# limit, ResponseTooLargeError is raised and the connection is closed.
308+
# See also #socket_read_limit.
309+
#
310+
# Note that changes will not take effect immediately, because the receiver
311+
# thread may already be waiting for the next response using the previous
312+
# value. Net::IMAP#noop can force a response and enforce the new setting
313+
# immediately.
314+
#
315+
# ==== Versioned Defaults
316+
#
317+
# Net::IMAP#max_response_size <em>was added in +v0.2.5+ and +v0.3.9+ as an
318+
# attr_accessor, and in +v0.4.20+ and +v0.5.7+ as a delegator to a config
319+
# attribute.</em>
320+
#
321+
# * original: +nil+ <em>(no limit)</em>
322+
# * +0.5+: 512 MiB
323+
attr_accessor :max_response_size
324+
287325
# The thread to receive exceptions.
288326
attr_accessor :client_thread
289327

@@ -1114,6 +1152,7 @@ def idle_done
11141152
# that the greeting is handled in the current thread,
11151153
# but all other responses are handled in the receiver
11161154
# thread.
1155+
# max_response_size:: See #max_response_size.
11171156
#
11181157
# The most common errors are:
11191158
#
@@ -1144,6 +1183,7 @@ def initialize(host, port_or_options = {},
11441183
@tagno = 0
11451184
@open_timeout = options[:open_timeout] || 30
11461185
@idle_response_timeout = options[:idle_response_timeout] || 5
1186+
@max_response_size = options[:max_response_size]
11471187
@parser = ResponseParser.new
11481188
@sock = tcp_socket(@host, @port)
11491189
@reader = ResponseReader.new(self, @sock)

‎lib/net/imap/errors.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,40 @@ class Error < StandardError
1111
class DataFormatError < Error
1212
end
1313

14+
# Error raised when the socket cannot be read, due to a configured limit.
15+
class ResponseReadError < Error
16+
end
17+
18+
# Error raised when a response is larger than IMAP#max_response_size.
19+
class ResponseTooLargeError < ResponseReadError
20+
attr_reader :bytes_read, :literal_size
21+
attr_reader :max_response_size
22+
23+
def initialize(msg = nil, *args,
24+
bytes_read: nil,
25+
literal_size: nil,
26+
max_response_size: nil,
27+
**kwargs)
28+
@bytes_read = bytes_read
29+
@literal_size = literal_size
30+
@max_response_size = max_response_size
31+
msg ||= [
32+
"Response size", response_size_msg, "exceeds max_response_size",
33+
max_response_size && "(#{max_response_size}B)",
34+
].compact.join(" ")
35+
return super(msg, *args) if kwargs.empty? # ruby 2.6 compatibility
36+
super(msg, *args, **kwargs)
37+
end
38+
39+
private
40+
41+
def response_size_msg
42+
if bytes_read && literal_size
43+
"(#{bytes_read}B read + #{literal_size}B literal)"
44+
end
45+
end
46+
end
47+
1448
# Error raised when a response from the server is non-parseable.
1549
class ResponseParseError < Error
1650
end

‎lib/net/imap/response_reader.rb

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

2929
attr_reader :buff, :literal_size
3030

31+
def bytes_read; buff.bytesize end
32+
def empty?; buff.empty? end
33+
def done?; line_done? && !get_literal_size end
34+
def line_done?; buff.end_with?(CRLF) end
3135
def get_literal_size; /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i end
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; client.max_response_size end
56+
def max_response_remaining; max_response_size &.- bytes_read end
57+
def response_too_large?; max_response_size &.< min_response_size end
58+
def min_response_size; bytes_read + min_response_remaining end
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: max_response_size,
68+
bytes_read: bytes_read,
69+
literal_size: literal_size,
70+
)
71+
end
72+
4473
end
4574
end
4675
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
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
require "test/unit"
5+
6+
class IMAPMaxResponseSizeTest < Test::Unit::TestCase
7+
8+
def setup
9+
@do_not_reverse_lookup = Socket.do_not_reverse_lookup
10+
Socket.do_not_reverse_lookup = true
11+
@threads = []
12+
end
13+
14+
def teardown
15+
if !@threads.empty?
16+
assert_join_threads(@threads)
17+
end
18+
ensure
19+
Socket.do_not_reverse_lookup = @do_not_reverse_lookup
20+
end
21+
22+
test "#max_response_size reading literals" do
23+
_, port = with_server_socket do |sock|
24+
sock.gets # => NOOP
25+
sock.print("RUBY0001 OK done\r\n")
26+
sock.gets # => NOOP
27+
sock.print("* 1 FETCH (BODY[] {12345}\r\n" + "a" * 12_345 + ")\r\n")
28+
sock.print("RUBY0002 OK done\r\n")
29+
"RUBY0003"
30+
end
31+
Timeout.timeout(5) do
32+
imap = Net::IMAP.new("localhost", port: port, max_response_size: 640 << 20)
33+
assert_equal 640 << 20, imap.max_response_size
34+
imap.max_response_size = 12_345 + 30
35+
assert_equal 12_345 + 30, imap.max_response_size
36+
imap.noop # to reset the get_response limit
37+
imap.noop # to send the FETCH
38+
assert_equal "a" * 12_345, imap.responses["FETCH"].first.attr["BODY[]"]
39+
ensure
40+
imap.logout rescue nil
41+
imap.disconnect rescue nil
42+
end
43+
end
44+
45+
test "#max_response_size closes connection for too long line" do
46+
_, port = with_server_socket do |sock|
47+
sock.gets or next # => never called
48+
fail "client disconnects first"
49+
end
50+
assert_raise_with_message(
51+
Net::IMAP::ResponseTooLargeError, /exceeds max_response_size .*\b10B\b/
52+
) do
53+
Net::IMAP.new("localhost", port: port, max_response_size: 10)
54+
fail "should not get here (greeting longer than max_response_size)"
55+
end
56+
end
57+
58+
test "#max_response_size closes connection for too long literal" do
59+
_, port = with_server_socket(ignore_io_error: true) do |sock|
60+
sock.gets # => NOOP
61+
sock.print "* 1 FETCH (BODY[] {1000}\r\n" + "a" * 1000 + ")\r\n"
62+
sock.print("RUBY0001 OK done\r\n")
63+
end
64+
client = Net::IMAP.new("localhost", port: port, max_response_size: 1000)
65+
assert_equal 1000, client.max_response_size
66+
client.max_response_size = 50
67+
assert_equal 50, client.max_response_size
68+
assert_raise_with_message(
69+
Net::IMAP::ResponseTooLargeError,
70+
/\d+B read \+ 1000B literal.* exceeds max_response_size .*\b50B\b/
71+
) do
72+
client.noop
73+
fail "should not get here (FETCH literal longer than max_response_size)"
74+
end
75+
end
76+
77+
def with_server_socket(ignore_io_error: false)
78+
server = create_tcp_server
79+
port = server.addr[1]
80+
start_server do
81+
Timeout.timeout(5) do
82+
sock = server.accept
83+
sock.print("* OK connection established\r\n")
84+
logout_tag = yield sock if block_given?
85+
sock.gets # => LOGOUT
86+
sock.print("* BYE terminating connection\r\n")
87+
sock.print("#{logout_tag} OK LOGOUT completed\r\n") if logout_tag
88+
rescue IOError, EOFError, Errno::ECONNABORTED, Errno::ECONNRESET,
89+
Errno::EPIPE, Errno::ETIMEDOUT
90+
ignore_io_error or raise
91+
ensure
92+
sock.close rescue nil
93+
server.close rescue nil
94+
end
95+
end
96+
return server, port
97+
end
98+
99+
def start_server
100+
th = Thread.new do
101+
yield
102+
end
103+
@threads << th
104+
sleep 0.1 until th.stop?
105+
end
106+
107+
def create_tcp_server
108+
return TCPServer.new(server_addr, 0)
109+
end
110+
111+
def server_addr
112+
Addrinfo.tcp("localhost", 0).ip_address
113+
end
114+
end

‎test/net/imap/test_response_reader.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
class ResponseReaderTest < Test::Unit::TestCase
88
class FakeClient
9+
attr_accessor :max_response_size
910
end
1011

1112
def literal(str) "{#{str.bytesize}}\r\n#{str}" end
@@ -44,4 +45,19 @@ def literal(str) "{#{str.bytesize}}\r\n#{str}" end
4445
assert_equal "", rcvr.read_response_buffer.to_str
4546
end
4647

48+
test "#read_response_buffer with max_response_size" do
49+
client = FakeClient.new
50+
client.max_response_size = 10
51+
under = "+ 3456\r\n"
52+
exact = "+ 345678\r\n"
53+
over = "+ 3456789\r\n"
54+
io = StringIO.new([under, exact, over].join)
55+
rcvr = Net::IMAP::ResponseReader.new(client, io)
56+
assert_equal under, rcvr.read_response_buffer.to_str
57+
assert_equal exact, rcvr.read_response_buffer.to_str
58+
assert_raise Net::IMAP::ResponseTooLargeError do
59+
rcvr.read_response_buffer
60+
end
61+
end
62+
4763
end

0 commit comments

Comments
 (0)