Skip to content

Commit 16c5fb6

Browse files
committed
✨ Add configurable socket_read_limit
Sets the limit for how many bytes will be read from the socket at a time. When both socket_read_limit and max_response_size are non-nil, reads are limited to the smaller of socket_read_limit and the remaining bytes for that response. When reading an IMAP `literal`, reads are also limited to the remaining bytes in that `literal` Please note that this only affects Net::IMAP's reads from the socket. The underlying IO or Socket buffers and system calls will not be directly affected by this limit. This is complicated by the possibility that `gets(CRLF, limit)` could stop in between a CR and a LF.
1 parent cf88ad8 commit 16c5fb6

File tree

7 files changed

+195
-5
lines changed

7 files changed

+195
-5
lines changed

lib/net/imap.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,9 @@ 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>
237+
# Use Config#max_response_size and Config#socket_read_limit to impose limits
238+
# on incoming server responses as they are being read. <em>This is especially
239+
# important for untrusted servers.</em>
240240
#
241241
# Use #add_response_handler to handle responses after each one is received.
242242
# Use the +response_handlers+ argument to ::new to assign response handlers
@@ -863,11 +863,18 @@ class << self
863863
# The maximum allowed server response size, in bytes.
864864
# Delegates to {config.max_response_size}[rdoc-ref:Config#max_response_size].
865865

866+
##
867+
# :attr_accessor: socket_read_limit
868+
# The limit for each socket read, in bytes.
869+
# Delegates to {config.socket_read_limit}[rdoc-ref:Config#socket_read_limit].
870+
866871
# :stopdoc:
867872
def open_timeout; config.open_timeout end
868873
def idle_response_timeout; config.idle_response_timeout end
869874
def max_response_size; config.max_response_size end
870875
def max_response_size=(val) config.max_response_size = val end
876+
def socket_read_limit; config.socket_read_limit end
877+
def socket_read_limit=(val) config.socket_read_limit = val end
871878
# :startdoc:
872879

873880
# The hostname this client connected to

lib/net/imap/config.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,31 @@ def self.[](config)
302302
# * +0.5+: 512 MiB
303303
attr_accessor :max_response_size, type: Integer?
304304

305+
# Sets the limit for how many bytes will be read from the socket at a
306+
# time.
307+
#
308+
# When both socket_read_limit and max_response_size are non-nil, reads are
309+
# limited to the smaller of #socket_read_limit and the remaining bytes for
310+
# that response. When reading an IMAP +literal+, reads are also limited
311+
# to the remaining bytes in that +literal+
312+
#
313+
# Please note that this only affects Net::IMAP's reads from its connection
314+
# object, which is buffered. The underlying buffers and IO system calls
315+
# will not be directly affected by this read limit.
316+
#
317+
# Related: #max_response_size
318+
#
319+
# ==== Versioned Defaults
320+
#
321+
# Net::IMAP#socket_read_limit _and_ Net::IMAP#socket_read_limit= <em>were
322+
# added in +v0.2.5+, +v0.3.9+, +v0.4.20+, and +v0.5.7+.</em>
323+
#
324+
# <em>Config option added in +v0.4.20+ and +v0.5.7+.</em>
325+
#
326+
# * original: +nil+ <em>(no limit)</em>
327+
# * +0.6+: 16KiB
328+
attr_accessor :socket_read_limit, type: Integer?
329+
305330
# Controls the behavior of Net::IMAP#responses when called without any
306331
# arguments (+type+ or +block+).
307332
#
@@ -482,6 +507,7 @@ def defaults_hash
482507
enforce_logindisabled: true,
483508
max_response_size: 512 << 20, # 512 MiB
484509
responses_without_block: :warn,
510+
socket_read_limit: nil,
485511
parser_use_deprecated_uidplus_data: :up_to_max_size,
486512
parser_max_deprecated_uidplus_data_size: 100,
487513
).freeze
@@ -520,6 +546,7 @@ def defaults_hash
520546
responses_without_block: :frozen_dup,
521547
parser_use_deprecated_uidplus_data: false,
522548
parser_max_deprecated_uidplus_data_size: 0,
549+
socket_read_limit: 16 << 10, # 16 KiB
523550
).freeze
524551

525552
version_defaults[0.7r] = Config[0.6r].dup.update(

lib/net/imap/errors.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,19 @@ def response_size_msg
5050
end
5151
end
5252

53+
# Error raised when Config#socket_read_limit is less than or equal to zero.
54+
class SocketReadLimitError < ResponseReadError
55+
attr_reader :socket_read_limit
56+
57+
def initialize(msg = nil, *args, socket_read_limit: nil, **kwargs)
58+
@socket_read_limit = socket_read_limit
59+
msg ||= [
60+
"Socket read limit", socket_read_limit, "<= 0",
61+
].compact.join(" ")
62+
super(msg, *args, **kwargs)
63+
end
64+
end
65+
5366
# Error raised when a response from the server is non-parsable.
5467
class ResponseParseError < Error
5568
end

lib/net/imap/response_reader.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,13 @@ def max_response_remaining!
8888
)
8989
end
9090

91-
# TODO: configurable socket_read_limit (currently hardcoded to 4KiB)
91+
# Raises SocketReadLimitError if socket_read_limit is zero or negative.
9292
def socket_read_limit!
93-
4096
93+
read_limit = client.socket_read_limit
94+
if read_limit && !read_limit.positive?
95+
raise SocketReadLimitError.new(socket_read_limit: read_limit)
96+
end
97+
read_limit
9498
end
9599

96100
end

test/net/imap/test_errors.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,18 @@ class IMAPErrorsTest < Test::Unit::TestCase
3737
"exceeds max_response_size (1200B)", err.message)
3838
end
3939

40+
test "SocketReadLimitError" do
41+
err = Net::IMAP::SocketReadLimitError.new("manually set message")
42+
assert_nil err.socket_read_limit
43+
assert_equal "manually set message", err.message
44+
45+
err = Net::IMAP::SocketReadLimitError.new
46+
assert_nil err.socket_read_limit
47+
assert_equal "Socket read limit <= 0", err.message
48+
49+
err = Net::IMAP::SocketReadLimitError.new(socket_read_limit: -1)
50+
assert_equal(-1, err.socket_read_limit)
51+
assert_equal "Socket read limit -1 <= 0", err.message
52+
end
53+
4054
end
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
require "test/unit"
5+
require_relative "fake_server"
6+
7+
class IMAPSocketReadLimitTest < 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+
def setup_various_responses_on_noop(server)
26+
server.on("NOOP") do |resp|
27+
# 10 byte response (prefix: "* ", suffix: "\r\n")
28+
resp.untagged("OK 678")
29+
# 20 byte response (prefix: "* ", suffix: "\r\n")
30+
resp.untagged("OK 6789012345678")
31+
# 21 byte response (prefix: "* ", suffix: "\r\n")
32+
resp.untagged("OK 67890123456789")
33+
# 22 byte response (prefix: "* ", suffix: "\r\n")
34+
resp.untagged("OK 678901234567890")
35+
# very large literal
36+
resp.untagged("1 FETCH (BODY[] {12345}\r\n" + "a" * 12_345 + ")")
37+
resp.done_ok
38+
end
39+
end
40+
41+
def setup_illegal_CRs_response_on_noop(server)
42+
# response with many illegal CR chars (not followed by LF)
43+
server.on("NOOP") do |resp|
44+
resp.untagged("OK #{"\r" * 50} Oops!")
45+
resp.done_ok
46+
end
47+
end
48+
49+
data " 1b", 1
50+
data " 2b", 2
51+
data " 9b", 9
52+
data "10b", 10
53+
data "20b", 20
54+
data "21b", 21
55+
data "22b", 22
56+
data " 1KiB", 1 << 10
57+
data "16KiB", 16 << 10
58+
data "16MiB", 16 << 20
59+
data "nil", nil
60+
test "#config.socket_read_limit" do |limit|
61+
Net::IMAP.config.max_response_size = nil
62+
Net::IMAP.config.socket_read_limit = limit
63+
with_fake_server do |server, imap|
64+
setup_various_responses_on_noop(server)
65+
responses = []
66+
imap.add_response_handler do
67+
responses << _1.data if _1 in Net::IMAP::UntaggedResponse
68+
end
69+
70+
imap.noop
71+
assert_equal 5, responses.count
72+
assert_equal "678", responses[0].text
73+
assert_equal "6789012345678", responses[1].text
74+
assert_equal "67890123456789", responses[2].text
75+
assert_equal "678901234567890", responses[3].text
76+
assert_equal "a" * 12_345, responses[4].message
77+
end
78+
79+
with_fake_server(ignore_io_error: true) do |server, imap|
80+
setup_illegal_CRs_response_on_noop(server)
81+
# ResponseParseError means it was successfully sent to the parser
82+
assert_raise(Net::IMAP::ResponseParseError) do
83+
imap.noop
84+
end
85+
end
86+
end
87+
88+
test "#config.socket_read_limit <= zero" do
89+
with_fake_server(ignore_io_error: true) do |server, imap|
90+
imap.config.socket_read_limit = 0
91+
assert_raise(Net::IMAP::SocketReadLimitError) do
92+
imap.noop
93+
end
94+
end
95+
with_fake_server(ignore_io_error: true) do |server, imap|
96+
imap.config.socket_read_limit = -1
97+
assert_raise(Net::IMAP::SocketReadLimitError) do
98+
imap.noop
99+
end
100+
end
101+
end
102+
103+
end

test/net/imap/test_response_reader.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def setup
1212
class FakeClient
1313
def config = @config ||= Net::IMAP.config.new
1414
def max_response_size = config.max_response_size
15+
def socket_read_limit = config.socket_read_limit
1516
end
1617

1718
def literal(str) = "{#{str.bytesize}}\r\n#{str}"
@@ -65,4 +66,25 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
6566
end
6667
end
6768

69+
test "#read_response_buffer with socket_read_limit" do
70+
client = FakeClient.new
71+
client.config.socket_read_limit = 10
72+
aaaaaaaaa = "a" * (20 << 10)
73+
many_crs = "\r" * 1000
74+
many_crlfs = "\r\n" * 100
75+
mega_response = [
76+
"* fake",
77+
aaaaaaaaa,
78+
literal(aaaaaaaaa),
79+
literal(many_crlfs),
80+
literal(literal(literal(many_crlfs))),
81+
literal(many_crs),
82+
many_crs,
83+
].join(" ")
84+
io = StringIO.new(mega_response)
85+
rcvr = Net::IMAP::ResponseReader.new(client, io)
86+
assert_equal mega_response, rcvr.read_response_buffer.to_str
87+
assert_equal "", rcvr.read_response_buffer.to_str
88+
end
89+
6890
end

0 commit comments

Comments
 (0)