Skip to content

Commit b6bdee2

Browse files
committed
✨ Make max_response_size configurable
Though it would be useful to also have limits based on response type and what commands are currently running, that's out of scope for now. _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 0ae8576 commit b6bdee2

File tree

7 files changed

+137
-11
lines changed

7 files changed

+137
-11
lines changed

lib/net/imap.rb

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

860+
##
861+
# :attr_accessor: max_response_size
862+
#
863+
# The maximum allowed server response size, in bytes.
864+
# Delegates to {config.max_response_size}[rdoc-ref:Config#max_response_size].
865+
856866
# :stopdoc:
857867
def open_timeout; config.open_timeout end
858868
def idle_response_timeout; config.idle_response_timeout end
869+
def max_response_size; config.max_response_size end
870+
def max_response_size=(val) config.max_response_size = val end
859871
# :startdoc:
860872

861873
# The hostname this client connected to

lib/net/imap/config.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,40 @@ def self.[](config)
268268
false, :when_capabilities_cached, true
269269
]
270270

271+
# The maximum allowed server response size. When +nil+, there is no limit
272+
# on response size.
273+
#
274+
# The default value (512 MiB, since +v0.5.7+) is <em>very high</em> and
275+
# unlikely to be reached. A _much_ lower value should be used with
276+
# untrusted servers (for example, when connecting to a user-provided
277+
# hostname). When using a lower limit, message bodies should be fetched
278+
# in chunks rather than all at once.
279+
#
280+
# <em>Please Note:</em> this only limits the size per response. It does
281+
# not prevent a flood of individual responses and it does not limit how
282+
# many unhandled responses may be stored on the responses hash. See
283+
# Net::IMAP@Unbounded+memory+use.
284+
#
285+
# Socket reads are limited to the maximum remaining bytes for the current
286+
# response: max_response_size minus the bytes that have already been read.
287+
# When the limit is reached, or reading a +literal+ _would_ go over the
288+
# limit, ResponseTooLargeError is raised and the connection is closed.
289+
#
290+
# Note that changes will not take effect immediately, because the receiver
291+
# thread may already be waiting for the next response using the previous
292+
# value. Net::IMAP#noop can force a response and enforce the new setting
293+
# immediately.
294+
#
295+
# ==== Versioned Defaults
296+
#
297+
# Net::IMAP#max_response_size <em>was added in +v0.2.5+ and +v0.3.9+ as an
298+
# attr_accessor, and in +v0.4.20+ and +v0.5.7+ as a delegator to this
299+
# config attribute.</em>
300+
#
301+
# * original: +nil+ <em>(no limit)</em>
302+
# * +0.5+: 512 MiB
303+
attr_accessor :max_response_size, type: Integer?
304+
271305
# Controls the behavior of Net::IMAP#responses when called without any
272306
# arguments (+type+ or +block+).
273307
#
@@ -446,6 +480,7 @@ def defaults_hash
446480
idle_response_timeout: 5,
447481
sasl_ir: true,
448482
enforce_logindisabled: true,
483+
max_response_size: 512 << 20, # 512 MiB
449484
responses_without_block: :warn,
450485
parser_use_deprecated_uidplus_data: :up_to_max_size,
451486
parser_max_deprecated_uidplus_data_size: 100,
@@ -459,6 +494,7 @@ def defaults_hash
459494
sasl_ir: false,
460495
responses_without_block: :silence_deprecation_warning,
461496
enforce_logindisabled: false,
497+
max_response_size: nil,
462498
parser_use_deprecated_uidplus_data: true,
463499
parser_max_deprecated_uidplus_data_size: 10_000,
464500
).freeze
@@ -474,6 +510,7 @@ def defaults_hash
474510

475511
version_defaults[0.5r] = Config[0.4r].dup.update(
476512
enforce_logindisabled: true,
513+
max_response_size: 512 << 20, # 512 MiB
477514
responses_without_block: :warn,
478515
parser_use_deprecated_uidplus_data: :up_to_max_size,
479516
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
2123
end
2224
private_constant :Macros
2325

@@ -39,6 +41,8 @@ def self.attr_accessor(attr, type: nil)
3941
define_method :"#{attr}?" do send attr end if type == Boolean
4042
end
4143

44+
NilOrInteger = safe{->val { Integer val unless val.nil? }}
45+
4246
Enum = ->(*enum) {
4347
enum = safe{enum}
4448
expected = -"one of #{enum.map(&:inspect).join(", ")}"

lib/net/imap/response_reader.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def read_limit(limit = nil)
5252
[limit, max_response_remaining!].compact.min
5353
end
5454

55-
def max_response_size = 512 << 20 # TODO: Config#max_response_size
55+
def max_response_size = client.max_response_size
5656
def max_response_remaining = max_response_size &.- bytes_read
5757
def response_too_large? = max_response_size &.< min_response_size
5858
def min_response_size = bytes_read + min_response_remaining

test/net/imap/test_config.rb

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

430+
test "#max_response_size=(Integer | nil)" do
431+
config = Config.new
432+
433+
config.max_response_size = 10_000
434+
assert_equal 10_000, config.max_response_size
435+
436+
config.max_response_size = nil
437+
assert_nil config.max_response_size
438+
439+
assert_raise(ArgumentError) do config.max_response_size = "invalid" end
440+
assert_raise(TypeError) do config.max_response_size = :invalid end
441+
end
442+
430443
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: 3 additions & 10 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
14+
def max_response_size = config.max_response_size
1415
end
1516

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

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-
6053
test "#read_response_buffer with max_response_size" do
6154
client = FakeClient.new
62-
max_response_size = 10
55+
client.config.max_response_size = 10
6356
under = "+ 3456\r\n"
6457
exact = "+ 345678\r\n"
6558
over = "+ 3456789\r\n"
6659
io = StringIO.new([under, exact, over].join)
67-
rcvr = LimitedResponseReader.new(client, io, max_response_size:)
60+
rcvr = Net::IMAP::ResponseReader.new(client, io)
6861
assert_equal under, rcvr.read_response_buffer.to_str
6962
assert_equal exact, rcvr.read_response_buffer.to_str
7063
assert_raise Net::IMAP::ResponseTooLargeError do

0 commit comments

Comments
 (0)