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