diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 3dd82ef2..d7e30454 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -234,6 +234,10 @@ module Net
#
# Use paginated or limited versions of commands whenever possible.
#
+ # Use Config#max_response_size to impose a limit on incoming server responses
+ # as they are being read. This is especially important for untrusted
+ # servers.
+ #
# Use #add_response_handler to handle responses after each one is received.
# Use the +response_handlers+ argument to ::new to assign response handlers
# before the receiver thread is started. Use #extract_responses,
@@ -853,9 +857,17 @@ class << self
# Seconds to wait until an IDLE response is received.
# Delegates to {config.idle_response_timeout}[rdoc-ref:Config#idle_response_timeout].
+ ##
+ # :attr_accessor: max_response_size
+ #
+ # The maximum allowed server response size, in bytes.
+ # Delegates to {config.max_response_size}[rdoc-ref:Config#max_response_size].
+
# :stopdoc:
def open_timeout; config.open_timeout end
def idle_response_timeout; config.idle_response_timeout end
+ def max_response_size; config.max_response_size end
+ def max_response_size=(val) config.max_response_size = val end
# :startdoc:
# The hostname this client connected to
diff --git a/lib/net/imap/config.rb b/lib/net/imap/config.rb
index a64ec3f2..013ccccc 100644
--- a/lib/net/imap/config.rb
+++ b/lib/net/imap/config.rb
@@ -268,6 +268,40 @@ def self.[](config)
false, :when_capabilities_cached, true
]
+ # The maximum allowed server response size. When +nil+, there is no limit
+ # on response size.
+ #
+ # The default value (512 MiB, since +v0.5.7+) is very high and
+ # unlikely to be reached. A _much_ lower value should be used with
+ # untrusted servers (for example, when connecting to a user-provided
+ # hostname). When using a lower limit, message bodies should be fetched
+ # in chunks rather than all at once.
+ #
+ # Please Note: this only limits the size per response. It does
+ # not prevent a flood of individual responses and it does not limit how
+ # many unhandled responses may be stored on the responses hash. See
+ # Net::IMAP@Unbounded+memory+use.
+ #
+ # Socket reads are limited to the maximum remaining bytes for the current
+ # response: max_response_size minus the bytes that have already been read.
+ # When the limit is reached, or reading a +literal+ _would_ go over the
+ # limit, ResponseTooLargeError is raised and the connection is closed.
+ #
+ # Note that changes will not take effect immediately, because the receiver
+ # thread may already be waiting for the next response using the previous
+ # value. Net::IMAP#noop can force a response and enforce the new setting
+ # immediately.
+ #
+ # ==== Versioned Defaults
+ #
+ # Net::IMAP#max_response_size was added in +v0.2.5+ and +v0.3.9+ as an
+ # attr_accessor, and in +v0.4.20+ and +v0.5.7+ as a delegator to this
+ # config attribute.
+ #
+ # * original: +nil+ (no limit)
+ # * +0.5+: 512 MiB
+ attr_accessor :max_response_size, type: Integer?
+
# Controls the behavior of Net::IMAP#responses when called without any
# arguments (+type+ or +block+).
#
@@ -446,6 +480,7 @@ def defaults_hash
idle_response_timeout: 5,
sasl_ir: true,
enforce_logindisabled: true,
+ max_response_size: 512 << 20, # 512 MiB
responses_without_block: :warn,
parser_use_deprecated_uidplus_data: :up_to_max_size,
parser_max_deprecated_uidplus_data_size: 100,
@@ -459,6 +494,7 @@ def defaults_hash
sasl_ir: false,
responses_without_block: :silence_deprecation_warning,
enforce_logindisabled: false,
+ max_response_size: nil,
parser_use_deprecated_uidplus_data: true,
parser_max_deprecated_uidplus_data_size: 10_000,
).freeze
@@ -474,6 +510,7 @@ def defaults_hash
version_defaults[0.5r] = Config[0.4r].dup.update(
enforce_logindisabled: true,
+ max_response_size: 512 << 20, # 512 MiB
responses_without_block: :warn,
parser_use_deprecated_uidplus_data: :up_to_max_size,
parser_max_deprecated_uidplus_data_size: 100,
diff --git a/lib/net/imap/config/attr_type_coercion.rb b/lib/net/imap/config/attr_type_coercion.rb
index 02632d50..264bc63a 100644
--- a/lib/net/imap/config/attr_type_coercion.rb
+++ b/lib/net/imap/config/attr_type_coercion.rb
@@ -18,6 +18,8 @@ def attr_accessor(attr, type: nil)
super(attr)
AttrTypeCoercion.attr_accessor(attr, type: type)
end
+
+ module_function def Integer? = NilOrInteger
end
private_constant :Macros
@@ -39,6 +41,8 @@ def self.attr_accessor(attr, type: nil)
define_method :"#{attr}?" do send attr end if type == Boolean
end
+ NilOrInteger = safe{->val { Integer val unless val.nil? }}
+
Enum = ->(*enum) {
enum = safe{enum}
expected = -"one of #{enum.map(&:inspect).join(", ")}"
diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb
index d329a513..bab4bbcf 100644
--- a/lib/net/imap/errors.rb
+++ b/lib/net/imap/errors.rb
@@ -17,6 +17,39 @@ def initialize(msg = "Remote server has disabled the LOGIN command", ...)
class DataFormatError < Error
end
+ # Error raised when the socket cannot be read, due to a Config limit.
+ class ResponseReadError < Error
+ end
+
+ # Error raised when a response is larger than IMAP#max_response_size.
+ class ResponseTooLargeError < ResponseReadError
+ attr_reader :bytes_read, :literal_size
+ attr_reader :max_response_size
+
+ def initialize(msg = nil, *args,
+ bytes_read: nil,
+ literal_size: nil,
+ max_response_size: nil,
+ **kwargs)
+ @bytes_read = bytes_read
+ @literal_size = literal_size
+ @max_response_size = max_response_size
+ msg ||= [
+ "Response size", response_size_msg, "exceeds max_response_size",
+ max_response_size && "(#{max_response_size}B)",
+ ].compact.join(" ")
+ super(msg, *args, **kwargs)
+ end
+
+ private
+
+ def response_size_msg
+ if bytes_read && literal_size
+ "(#{bytes_read}B read + #{literal_size}B literal)"
+ end
+ end
+ end
+
# Error raised when a response from the server is non-parsable.
class ResponseParseError < Error
end
diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb
index 7d3a5368..d3e819c0 100644
--- a/lib/net/imap/response_reader.rb
+++ b/lib/net/imap/response_reader.rb
@@ -28,19 +28,46 @@ def read_response_buffer
attr_reader :buff, :literal_size
+ def bytes_read = buff.bytesize
+ def empty? = buff.empty?
+ def done? = line_done? && !get_literal_size
+ def line_done? = buff.end_with?(CRLF)
def get_literal_size = /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i
def read_line
- buff << (@sock.gets(CRLF) or throw :eof)
+ buff << (@sock.gets(CRLF, read_limit) or throw :eof)
+ max_response_remaining! unless line_done?
end
def read_literal
+ # check before allocating memory for literal
+ max_response_remaining!
literal = String.new(capacity: literal_size)
- buff << (@sock.read(literal_size, literal) or throw :eof)
+ buff << (@sock.read(read_limit(literal_size), literal) or throw :eof)
ensure
@literal_size = nil
end
+ def read_limit(limit = nil)
+ [limit, max_response_remaining!].compact.min
+ end
+
+ def max_response_size = client.max_response_size
+ def max_response_remaining = max_response_size &.- bytes_read
+ def response_too_large? = max_response_size &.< min_response_size
+ def min_response_size = bytes_read + min_response_remaining
+
+ def min_response_remaining
+ empty? ? 3 : done? ? 0 : (literal_size || 0) + 2
+ end
+
+ def max_response_remaining!
+ return max_response_remaining unless response_too_large?
+ raise ResponseTooLargeError.new(
+ max_response_size:, bytes_read:, literal_size:,
+ )
+ end
+
end
end
end
diff --git a/test/net/imap/test_config.rb b/test/net/imap/test_config.rb
index 907f360a..aa9ed5e7 100644
--- a/test/net/imap/test_config.rb
+++ b/test/net/imap/test_config.rb
@@ -427,4 +427,17 @@ def duck.to_r = 1/11111
assert_same grandchild, greatgrandchild.parent
end
+ test "#max_response_size=(Integer | nil)" do
+ config = Config.new
+
+ config.max_response_size = 10_000
+ assert_equal 10_000, config.max_response_size
+
+ config.max_response_size = nil
+ assert_nil config.max_response_size
+
+ assert_raise(ArgumentError) do config.max_response_size = "invalid" end
+ assert_raise(TypeError) do config.max_response_size = :invalid end
+ end
+
end
diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb
new file mode 100644
index 00000000..a6a7cb0f
--- /dev/null
+++ b/test/net/imap/test_errors.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "net/imap"
+require "test/unit"
+
+class IMAPErrorsTest < Test::Unit::TestCase
+
+ test "ResponseTooLargeError" do
+ err = Net::IMAP::ResponseTooLargeError.new
+ assert_nil err.bytes_read
+ assert_nil err.literal_size
+ assert_nil err.max_response_size
+
+ err = Net::IMAP::ResponseTooLargeError.new("manually set message")
+ assert_equal "manually set message", err.message
+ assert_nil err.bytes_read
+ assert_nil err.literal_size
+ assert_nil err.max_response_size
+
+ err = Net::IMAP::ResponseTooLargeError.new(max_response_size: 1024)
+ assert_equal "Response size exceeds max_response_size (1024B)", err.message
+ assert_nil err.bytes_read
+ assert_nil err.literal_size
+ assert_equal 1024, err.max_response_size
+
+ err = Net::IMAP::ResponseTooLargeError.new(bytes_read: 1200,
+ max_response_size: 1200)
+ assert_equal 1200, err.bytes_read
+ assert_equal "Response size exceeds max_response_size (1200B)", err.message
+
+ err = Net::IMAP::ResponseTooLargeError.new(bytes_read: 800,
+ literal_size: 1000,
+ max_response_size: 1200)
+ assert_equal 800, err.bytes_read
+ assert_equal 1000, err.literal_size
+ assert_equal("Response size (800B read + 1000B literal) " \
+ "exceeds max_response_size (1200B)", err.message)
+ end
+
+end
diff --git a/test/net/imap/test_imap_max_response_size.rb b/test/net/imap/test_imap_max_response_size.rb
new file mode 100644
index 00000000..3751d0bc
--- /dev/null
+++ b/test/net/imap/test_imap_max_response_size.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "net/imap"
+require "test/unit"
+require_relative "fake_server"
+
+class IMAPMaxResponseSizeTest < Test::Unit::TestCase
+ include Net::IMAP::FakeServer::TestHelper
+
+ def setup
+ Net::IMAP.config.reset
+ @do_not_reverse_lookup = Socket.do_not_reverse_lookup
+ Socket.do_not_reverse_lookup = true
+ @threads = []
+ end
+
+ def teardown
+ if !@threads.empty?
+ assert_join_threads(@threads)
+ end
+ ensure
+ Socket.do_not_reverse_lookup = @do_not_reverse_lookup
+ end
+
+ test "#max_response_size reading literals" do
+ with_fake_server(preauth: true) do |server, imap|
+ imap.max_response_size = 12_345 + 30
+ server.on("NOOP") do |resp|
+ resp.untagged("1 FETCH (BODY[] {12345}\r\n" + "a" * 12_345 + ")")
+ resp.done_ok
+ end
+ imap.noop
+ assert_equal "a" * 12_345, imap.responses("FETCH").first.message
+ end
+ end
+
+ test "#max_response_size closes connection for too long line" do
+ Net::IMAP.config.max_response_size = 10
+ run_fake_server_in_thread(preauth: false, ignore_io_error: true) do |server|
+ assert_raise_with_message(
+ Net::IMAP::ResponseTooLargeError, /exceeds max_response_size .*\b10B\b/
+ ) do
+ with_client("localhost", port: server.port) do
+ fail "should not get here (greeting longer than max_response_size)"
+ end
+ end
+ end
+ end
+
+ test "#max_response_size closes connection for too long literal" do
+ Net::IMAP.config.max_response_size = 1<<20
+ with_fake_server(preauth: false, ignore_io_error: true) do |server, client|
+ client.max_response_size = 50
+ server.on("NOOP") do |resp|
+ resp.untagged("1 FETCH (BODY[] {1000}\r\n" + "a" * 1000 + ")")
+ end
+ assert_raise_with_message(
+ Net::IMAP::ResponseTooLargeError,
+ /\d+B read \+ 1000B literal.* exceeds max_response_size .*\b50B\b/
+ ) do
+ client.noop
+ fail "should not get here (FETCH literal longer than max_response_size)"
+ end
+ end
+ end
+
+end
diff --git a/test/net/imap/test_response_reader.rb b/test/net/imap/test_response_reader.rb
index 159002c3..5d64c07a 100644
--- a/test/net/imap/test_response_reader.rb
+++ b/test/net/imap/test_response_reader.rb
@@ -11,6 +11,7 @@ def setup
class FakeClient
def config = @config ||= Net::IMAP.config.new
+ def max_response_size = config.max_response_size
end
def literal(str) = "{#{str.bytesize}}\r\n#{str}"
@@ -49,4 +50,19 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
assert_equal "", rcvr.read_response_buffer.to_str
end
+ test "#read_response_buffer with max_response_size" do
+ client = FakeClient.new
+ client.config.max_response_size = 10
+ under = "+ 3456\r\n"
+ exact = "+ 345678\r\n"
+ over = "+ 3456789\r\n"
+ io = StringIO.new([under, exact, over].join)
+ rcvr = Net::IMAP::ResponseReader.new(client, io)
+ assert_equal under, rcvr.read_response_buffer.to_str
+ assert_equal exact, rcvr.read_response_buffer.to_str
+ assert_raise Net::IMAP::ResponseTooLargeError do
+ rcvr.read_response_buffer
+ end
+ end
+
end