Skip to content

Commit 8222a36

Browse files
authored
🔀 Merge pull request #445 from ruby/backport/v0.4-max_response_size
✨ Limit max_response_size (backport 0.4)
2 parents 2ca4dbc + 641c4c4 commit 8222a36

9 files changed

+254
-2
lines changed

‎lib/net/imap.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,10 @@ module Net
232232
#
233233
# Use paginated or limited versions of commands whenever possible.
234234
#
235+
# Use Config#max_response_size to impose a limit on incoming server responses
236+
# as they are being read. <em>This is especially important for untrusted
237+
# servers.</em>
238+
#
235239
# Use #add_response_handler to handle responses after each one is received.
236240
# Use the +response_handlers+ argument to ::new to assign response handlers
237241
# before the receiver thread is started. Use #extract_responses,
@@ -824,9 +828,17 @@ class << self
824828
# Seconds to wait until an IDLE response is received.
825829
# Delegates to {config.idle_response_timeout}[rdoc-ref:Config#idle_response_timeout].
826830

831+
##
832+
# :attr_accessor: max_response_size
833+
#
834+
# The maximum allowed server response size, in bytes.
835+
# Delegates to {config.max_response_size}[rdoc-ref:Config#max_response_size].
836+
827837
# :stopdoc:
828838
def open_timeout; config.open_timeout end
829839
def idle_response_timeout; config.idle_response_timeout end
840+
def max_response_size; config.max_response_size end
841+
def max_response_size=(val) config.max_response_size = val end
830842
# :startdoc:
831843

832844
# The hostname this client connected to

‎lib/net/imap/config.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,41 @@ def self.[](config)
242242
# Use +SASL-IR+ when it is supported by the server and the mechanism.
243243
attr_accessor :sasl_ir, type: :boolean
244244

245+
# The maximum allowed server response size. When +nil+, there is no limit
246+
# on response size.
247+
#
248+
# The default value (512 MiB, since +v0.5.7+) is <em>very high</em> and
249+
# unlikely to be reached. To use a lower limit, fetch message bodies in
250+
# chunks rather than all at once. A _much_ lower value should be used
251+
# with untrusted servers (for example, when connecting to a user-provided
252+
# hostname).
253+
#
254+
# <em>Please Note:</em> this only limits the size per response. It does
255+
# not prevent a flood of individual responses and it does not limit how
256+
# many unhandled responses may be stored on the responses hash. See
257+
# Net::IMAP@Unbounded+memory+use.
258+
#
259+
# Socket reads are limited to the maximum remaining bytes for the current
260+
# response: max_response_size minus the bytes that have already been read.
261+
# When the limit is reached, or reading a +literal+ _would_ go over the
262+
# limit, ResponseTooLargeError is raised and the connection is closed.
263+
# See also #socket_read_limit.
264+
#
265+
# Note that changes will not take effect immediately, because the receiver
266+
# thread may already be waiting for the next response using the previous
267+
# value. Net::IMAP#noop can force a response and enforce the new setting
268+
# immediately.
269+
#
270+
# ==== Versioned Defaults
271+
#
272+
# Net::IMAP#max_response_size <em>was added in +v0.2.5+ and +v0.3.9+ as an
273+
# attr_accessor, and in +v0.4.20+ and +v0.5.7+ as a delegator to this
274+
# config attribute.</em>
275+
#
276+
# * original: +nil+ <em>(no limit)</em>
277+
# * +0.5+: 512 MiB
278+
attr_accessor :max_response_size, type: Integer?
279+
245280
# Controls the behavior of Net::IMAP#responses when called without any
246281
# arguments (+type+ or +block+).
247282
#
@@ -419,6 +454,7 @@ def defaults_hash
419454
open_timeout: 30,
420455
idle_response_timeout: 5,
421456
sasl_ir: true,
457+
max_response_size: nil,
422458
responses_without_block: :silence_deprecation_warning,
423459
parser_use_deprecated_uidplus_data: true,
424460
parser_max_deprecated_uidplus_data_size: 1000,
@@ -430,6 +466,7 @@ def defaults_hash
430466

431467
version_defaults[0r] = Config[:default].dup.update(
432468
sasl_ir: false,
469+
max_response_size: nil,
433470
parser_use_deprecated_uidplus_data: true,
434471
parser_max_deprecated_uidplus_data_size: 10_000,
435472
).freeze
@@ -444,6 +481,7 @@ def defaults_hash
444481
).freeze
445482

446483
version_defaults[0.5r] = Config[0.4r].dup.update(
484+
max_response_size: 512 << 20, # 512 MiB
447485
responses_without_block: :warn,
448486
parser_use_deprecated_uidplus_data: :up_to_max_size,
449487
parser_max_deprecated_uidplus_data_size: 100,

‎lib/net/imap/config/attr_type_coercion.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def attr_accessor(attr, type: nil)
1818
super(attr)
1919
AttrTypeCoercion.attr_accessor(attr, type: type)
2020
end
21+
22+
module_function def Integer?; NilOrInteger end
2123
end
2224
private_constant :Macros
2325

@@ -46,6 +48,8 @@ def self.attr_accessor(attr, type: nil)
4648
define_method :"#{attr}?" do send attr end if type == Boolean
4749
end
4850

51+
NilOrInteger = safe{->val { Integer val unless val.nil? }}
52+
4953
Enum = ->(*enum) {
5054
enum = safe{enum}
5155
expected = -"one of #{enum.map(&:inspect).join(", ")}"

‎lib/net/imap/errors.rb

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

14+
# Error raised when the socket cannot be read, due to a Config 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+
super(msg, *args, **kwargs)
36+
end
37+
38+
private
39+
40+
def response_size_msg
41+
if bytes_read && literal_size
42+
"(#{bytes_read}B read + #{literal_size}B literal)"
43+
end
44+
end
45+
end
46+
1447
# Error raised when a response from the server is non-parsable.
1548
class ResponseParseError < Error
1649
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_config.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,4 +428,17 @@ def duck.to_r; 1/11111 end
428428
assert_same grandchild, greatgrandchild.parent
429429
end
430430

431+
test "#max_response_size=(Integer | nil)" do
432+
config = Config.new
433+
434+
config.max_response_size = 10_000
435+
assert_equal 10_000, config.max_response_size
436+
437+
config.max_response_size = nil
438+
assert_nil config.max_response_size
439+
440+
assert_raise(ArgumentError) do config.max_response_size = "invalid" end
441+
assert_raise(TypeError) do config.max_response_size = :invalid end
442+
end
443+
431444
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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
require "test/unit"
5+
require_relative "fake_server"
6+
7+
class IMAPMaxResponseSizeTest < Test::Unit::TestCase
8+
include Net::IMAP::FakeServer::TestHelper
9+
10+
def setup
11+
Net::IMAP.config.reset
12+
@do_not_reverse_lookup = Socket.do_not_reverse_lookup
13+
Socket.do_not_reverse_lookup = true
14+
@threads = []
15+
end
16+
17+
def teardown
18+
if !@threads.empty?
19+
assert_join_threads(@threads)
20+
end
21+
ensure
22+
Socket.do_not_reverse_lookup = @do_not_reverse_lookup
23+
end
24+
25+
test "#max_response_size reading literals" do
26+
with_fake_server(preauth: true) do |server, imap|
27+
imap.max_response_size = 12_345 + 30
28+
server.on("NOOP") do |resp|
29+
resp.untagged("1 FETCH (BODY[] {12345}\r\n" + "a" * 12_345 + ")")
30+
resp.done_ok
31+
end
32+
imap.noop
33+
assert_equal "a" * 12_345, imap.responses("FETCH").first.message
34+
end
35+
end
36+
37+
test "#max_response_size closes connection for too long line" do
38+
Net::IMAP.config.max_response_size = 10
39+
run_fake_server_in_thread(preauth: false, ignore_io_error: true) do |server|
40+
assert_raise_with_message(
41+
Net::IMAP::ResponseTooLargeError, /exceeds max_response_size .*\b10B\b/
42+
) do
43+
with_client("localhost", port: server.port) do
44+
fail "should not get here (greeting longer than max_response_size)"
45+
end
46+
end
47+
end
48+
end
49+
50+
test "#max_response_size closes connection for too long literal" do
51+
Net::IMAP.config.max_response_size = 1<<20
52+
with_fake_server(preauth: false, ignore_io_error: true) do |server, client|
53+
client.max_response_size = 50
54+
server.on("NOOP") do |resp|
55+
resp.untagged("1 FETCH (BODY[] {1000}\r\n" + "a" * 1000 + ")")
56+
end
57+
assert_raise_with_message(
58+
Net::IMAP::ResponseTooLargeError,
59+
/\d+B read \+ 1000B literal.* exceeds max_response_size .*\b50B\b/
60+
) do
61+
client.noop
62+
fail "should not get here (FETCH literal longer than max_response_size)"
63+
end
64+
end
65+
end
66+
67+
end

‎test/net/imap/test_response_reader.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ def setup
1111

1212
class FakeClient
1313
def config; @config ||= Net::IMAP.config.new end
14+
def max_response_size; config.max_response_size end
1415
end
1516

1617
def literal(str) "{#{str.bytesize}}\r\n#{str}" end
@@ -49,4 +50,19 @@ def literal(str) "{#{str.bytesize}}\r\n#{str}" end
4950
assert_equal "", rcvr.read_response_buffer.to_str
5051
end
5152

53+
test "#read_response_buffer with max_response_size" do
54+
client = FakeClient.new
55+
client.config.max_response_size = 10
56+
under = "+ 3456\r\n"
57+
exact = "+ 345678\r\n"
58+
over = "+ 3456789\r\n"
59+
io = StringIO.new([under, exact, over].join)
60+
rcvr = Net::IMAP::ResponseReader.new(client, io)
61+
assert_equal under, rcvr.read_response_buffer.to_str
62+
assert_equal exact, rcvr.read_response_buffer.to_str
63+
assert_raise Net::IMAP::ResponseTooLargeError do
64+
rcvr.read_response_buffer
65+
end
66+
end
67+
5268
end

0 commit comments

Comments
 (0)