cool.io 1.9.4 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/ext/libev/ev_wrap.h CHANGED
@@ -98,6 +98,8 @@
98
98
  #define vec_ro ((loop)->vec_ro)
99
99
  #define vec_wi ((loop)->vec_wi)
100
100
  #define vec_wo ((loop)->vec_wo)
101
+ #define win32_fs ((loop)->win32_fs)
102
+ #define win32_fs_disabled ((loop)->win32_fs_disabled)
101
103
  #else
102
104
  #undef EV_WRAP_H
103
105
  #undef acquire_cb
@@ -197,4 +199,6 @@
197
199
  #undef vec_ro
198
200
  #undef vec_wi
199
201
  #undef vec_wo
202
+ #undef win32_fs
203
+ #undef win32_fs_disabled
200
204
  #endif
data/gems.rb CHANGED
@@ -7,3 +7,7 @@ group :maintenance, optional: true do
7
7
  gem "bake-gem"
8
8
  gem "bake-modernize"
9
9
  end
10
+
11
+ group :development, :test do
12
+ gem 'ruby_memcheck', '~> 3.0' if RUBY_PLATFORM.include?('linux')
13
+ end
@@ -18,6 +18,8 @@
18
18
  #++
19
19
 
20
20
  require 'resolv'
21
+ require 'securerandom'
22
+ require 'socket'
21
23
 
22
24
  module Coolio
23
25
  # A non-blocking DNS resolver. It provides interfaces for querying both
@@ -67,14 +69,22 @@ module Coolio
67
69
  # list of nameservers to query. By default the resolver will
68
70
  # use nameservers listed in /etc/resolv.conf
69
71
  def initialize(hostname, *nameservers)
72
+ nameservers = reject_ipv6_nameservers(nameservers)
70
73
  if nameservers.empty?
71
- nameservers = Resolv::DNS::Config.default_config_hash[:nameserver]
74
+ nameservers = reject_ipv6_nameservers(Resolv::DNS::Config.default_config_hash[:nameserver])
72
75
  raise RuntimeError, "no nameservers found" if nameservers.empty? # TODO just call resolve_failed, not raise [also handle Errno::ENOENT)]
73
76
  end
74
77
 
75
78
  @nameservers = nameservers.dup
76
79
  @question = request_question hostname
77
80
 
81
+ # A guessable ID would let an off-path attacker forge a response
82
+ @request_id = SecureRandom.random_number(1 << 16)
83
+
84
+ # Numeric addresses this query was sent to, and the lookups behind them
85
+ @queried_addresses = []
86
+ @numeric_addresses = {}
87
+
78
88
  @socket = UDPSocket.new
79
89
  @timer = Timeout.new(self)
80
90
 
@@ -113,27 +123,44 @@ module Coolio
113
123
 
114
124
  # Send a request to the DNS server
115
125
  def send_request
116
- nameserver = @nameservers.shift
117
- @nameservers << nameserver # rotate them
126
+ @nameservers.rotate!
127
+
128
+ # Send to the numeric address, so we know where a response must come from
129
+ address = numeric_address(@nameservers.first)
130
+ @queried_addresses << address unless @queried_addresses.include?(address)
131
+
118
132
  begin
119
- @socket.send request_message, 0, @nameservers.first, DNS_PORT
133
+ @socket.send request_message, 0, address, DNS_PORT
120
134
  rescue Errno::EHOSTUNREACH # TODO figure out why it has to be wrapper here, when the other wrapper should be wrapping this one!
121
135
  end
122
136
  end
123
137
 
124
138
  # Called by the subclass when the DNS response is available
125
139
  def on_readable
126
- datagram = nil
140
+ datagram = sender = nil
127
141
  begin
128
- datagram = @socket.recvfrom_nonblock(DATAGRAM_SIZE).first
142
+ datagram, sender = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
129
143
  rescue Errno::ECONNREFUSED
130
144
  end
131
145
 
146
+ # Ignore anything we didn't ask for, rather than resolving or failing on it.
147
+ # The query stays outstanding, so the retry timer still bounds us.
148
+ return if datagram and not solicited_response?(datagram, sender)
149
+
132
150
  address = response_address datagram rescue nil
133
151
  address ? on_success(address) : on_failure
134
152
  detach
135
153
  end
136
154
 
155
+ # Is this a reply to our query, from an address we sent it to?
156
+ # Retries rotate through @nameservers, so any address already queried counts.
157
+ def solicited_response?(datagram, sender)
158
+ return false unless datagram.size >= 12
159
+ return false unless sender and sender[1] == DNS_PORT and @queried_addresses.include?(sender[3])
160
+
161
+ datagram[0..1].unpack('n').first.to_i == @request_id
162
+ end
163
+
137
164
  def request_question(hostname)
138
165
  raise ArgumentError, "hostname cannot be nil" if hostname.nil?
139
166
 
@@ -151,7 +178,7 @@ module Coolio
151
178
 
152
179
  def request_message
153
180
  # Standard query header
154
- message = [2, 1, 0].pack('nCC')
181
+ message = [@request_id, 1, 0].pack('nCC')
155
182
 
156
183
  # One entry
157
184
  qdcount = 1
@@ -166,7 +193,7 @@ module Coolio
166
193
  def response_address(message)
167
194
  # Confirm the ID field
168
195
  id = message[0..1].unpack('n').first.to_i
169
- return unless id == 2
196
+ return unless id == @request_id
170
197
 
171
198
  # Check the QR value and confirm this message is a response
172
199
  qr = message[2..2].unpack('B1').first.to_i
@@ -191,6 +218,7 @@ module Coolio
191
218
  while not message.empty?
192
219
  type = message[2..3].unpack('n').first.to_i
193
220
  rdlength = message[10..11].unpack('n').first.to_i
221
+ return if message.bytesize < 12 + rdlength
194
222
  rdata = message[12..(12 + rdlength - 1)]
195
223
  message.slice!(0, 12 + rdlength)
196
224
 
@@ -204,6 +232,23 @@ module Coolio
204
232
  nil
205
233
  end
206
234
 
235
+ private
236
+
237
+ def reject_ipv6_nameservers(nameservers)
238
+ nameservers.reject { |ns| ns.include?(':') }
239
+ end
240
+
241
+ # The address of a nameserver, which may be given as a hostname.
242
+ # Only successful lookups are cached, so a transient failure is looked up again.
243
+ def numeric_address(nameserver)
244
+ @numeric_addresses[nameserver] ||= begin
245
+ addrinfo = Addrinfo.getaddrinfo(nameserver, nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first
246
+ raise SocketError, "getaddrinfo: no IPv4 address for #{nameserver}" if addrinfo.nil?
247
+
248
+ addrinfo.ip_address
249
+ end
250
+ end
251
+
207
252
  class Timeout < TimerWatcher
208
253
  def initialize(resolver)
209
254
  @resolver = resolver
data/lib/cool.io/loop.rb CHANGED
@@ -38,6 +38,10 @@ module Coolio
38
38
  # :kqueue (BSD/Mac OS X)
39
39
  # :port (Solaris 10)
40
40
  #
41
+ # Raises RuntimeError if none of the selected backends is usable.
42
+ # Unless :skip_environment is true, $LIBEV_FLAGS can override the
43
+ # backend selection and cause the same error.
44
+ #
41
45
  def initialize(options = {})
42
46
  @watchers = {}
43
47
  @active_watchers = 0
@@ -47,7 +51,7 @@ module Coolio
47
51
  options.each do |option, value|
48
52
  case option
49
53
  when :skip_environment
50
- flags |= EVFLAG_NOEV if value
54
+ flags |= EVFLAG_NOENV if value
51
55
  when :fork_check
52
56
  flags |= EVFLAG_FORKCHECK if value
53
57
  when :backend
@@ -1,6 +1,6 @@
1
1
  module Coolio
2
- VERSION = "1.9.4"
3
-
2
+ VERSION = "1.10.0"
3
+
4
4
  def self.version
5
5
  VERSION
6
6
  end
@@ -1,20 +1,26 @@
1
1
  diff --git a/ext/libev/ev.c b/ext/libev/ev.c
2
- index dae87f1..d15f6bd 100644
2
+ index a59efb2..5c18fd6 100644
3
3
  --- a/ext/libev/ev.c
4
4
  +++ b/ext/libev/ev.c
5
- @@ -207,6 +207,7 @@
5
+ @@ -210,6 +210,13 @@
6
6
  #else
7
7
  # include <io.h>
8
8
  # define WIN32_LEAN_AND_MEAN
9
- +# define FD_SETSIZE 1024
9
+ +/* ruby.h above already pulled in winsock2.h, so fd_set may be dimensioned
10
+ + * already. Defining FD_SETSIZE unconditionally here would only move the bound
11
+ + * used by EV_WIN_FD_SET, not the array it indexes. Take whatever is in effect
12
+ + * and only supply a default when nothing has been decided yet. */
13
+ +# ifndef FD_SETSIZE
14
+ +# define FD_SETSIZE 1024
15
+ +# endif
10
16
  # include <winsock2.h>
11
17
  # include <windows.h>
12
18
  # ifndef EV_SELECT_IS_WINSOCKET
13
19
  diff --git a/ext/libev/ev_select.c b/ext/libev/ev_select.c
14
- index f38d6ca..7050778 100644
20
+ index ed1fc7a..eccff2b 100644
15
21
  --- a/ext/libev/ev_select.c
16
22
  +++ b/ext/libev/ev_select.c
17
- @@ -67,6 +67,54 @@
23
+ @@ -67,6 +67,64 @@
18
24
 
19
25
  #include <string.h>
20
26
 
@@ -58,6 +64,16 @@ index f38d6ca..7050778 100644
58
64
  +#define EV_WIN_FD_ZERO(set) (((fd_set *)(set))->fd_count=0)
59
65
  +#define EV_WIN_FD_ISSET(fd, set) __WSAFDIsSet((SOCKET)(fd), (fd_set *)(set))
60
66
  +#define EV_WIN_FD_COUNT(set) (((fd_set *)(set))->fd_count)
67
+ +
68
+ +/*
69
+ +fd_set is dimensioned by whatever FD_SETSIZE was in effect when winsock2.h was
70
+ +first pulled in, but EV_WIN_FD_SET and select_modify bound-check against the
71
+ +FD_SETSIZE visible here. The two are decided by separate paths, so if the bound
72
+ +ever exceeds the declared array we would write past the end of the allocation in
73
+ +select_init. Catch that at build time instead.
74
+ +*/
75
+ +typedef char coolio_fd_setsize_matches_fd_set[
76
+ + (sizeof (((fd_set *)0)->fd_array) / sizeof (SOCKET) >= (size_t)FD_SETSIZE) ? 1 : -1];
61
77
  +/* ######################################## */
62
78
  +#else
63
79
  +#define EV_WIN_FD_CLR FD_CLR
@@ -69,7 +85,7 @@ index f38d6ca..7050778 100644
69
85
  static void
70
86
  select_modify (EV_P_ int fd, int oev, int nev)
71
87
  {
72
- @@ -91,17 +139,17 @@ select_modify (EV_P_ int fd, int oev, int nev)
88
+ @@ -91,17 +149,17 @@ select_modify (EV_P_ int fd, int oev, int nev)
73
89
  if ((oev ^ nev) & EV_READ)
74
90
  #endif
75
91
  if (nev & EV_READ)
@@ -91,7 +107,7 @@ index f38d6ca..7050778 100644
91
107
 
92
108
  #else
93
109
 
94
- @@ -197,8 +245,8 @@ select_poll (EV_P_ ev_tstamp timeout)
110
+ @@ -197,8 +255,8 @@ select_poll (EV_P_ ev_tstamp timeout)
95
111
  {
96
112
  if (timeout)
97
113
  {
@@ -102,7 +118,7 @@ index f38d6ca..7050778 100644
102
118
  }
103
119
 
104
120
  return;
105
- @@ -230,10 +278,10 @@ select_poll (EV_P_ ev_tstamp timeout)
121
+ @@ -230,10 +288,10 @@ select_poll (EV_P_ ev_tstamp timeout)
106
122
  int handle = fd;
107
123
  #endif
108
124
 
@@ -116,7 +132,7 @@ index f38d6ca..7050778 100644
116
132
  #endif
117
133
 
118
134
  if (expect_true (events))
119
- @@ -279,9 +327,9 @@ select_init (EV_P_ int flags)
135
+ @@ -280,9 +338,9 @@ select_init (EV_P_ int flags)
120
136
  backend_poll = select_poll;
121
137
 
122
138
  #if EV_SELECT_USE_FD_SET
@@ -30,7 +30,9 @@ describe Cool.io::AsyncWatcher, :env => :exclude_win do
30
30
  end
31
31
 
32
32
  # ensure children are ready
33
- nr_fork.times { expect(rd.sysread(1)).to eq('.') }
33
+ # rd may be O_NONBLOCK on macOS Ruby 3.2+ (IO.pipe sets O_NONBLOCK); use read
34
+ # instead of sysread so EAGAIN on an empty pipe is retried transparently.
35
+ nr_fork.times { expect(rd.read(1)).to eq('.') }
34
36
 
35
37
  # send our signals
36
38
  nr_signal.times { aw.signal }
@@ -1,10 +1,13 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Cool.io::Loop do
4
+ # An IOWatcher that drains its socket and then runs a user-supplied block,
5
+ # receiving itself as the argument.
4
6
  class Victim < Cool.io::IOWatcher
5
- def initialize(io)
6
- super
7
+ def initialize(io, &on_readable)
8
+ super(io)
7
9
  @io = io
10
+ @on_readable = on_readable
8
11
  end
9
12
 
10
13
  def on_readable
@@ -12,40 +15,71 @@ describe Cool.io::Loop do
12
15
  @io.read_nonblock(1024)
13
16
  rescue IO::WaitReadable, EOFError
14
17
  end
18
+ @on_readable.call(self) if @on_readable
15
19
  end
16
20
  end
17
21
 
18
22
  # https://github.com/socketry/cool.io/issues/87
19
- it "does not raise TypeError when a watcher is detached while an event is pending" do
20
- loop = Cool.io::Loop.default
21
-
23
+ #
24
+ # Several watchers have an event pending in the same loop iteration. When the
25
+ # first one dispatched detaches the others, the loop must skip their now-stale
26
+ # pending events instead of dispatching them to a detached watcher. Before the
27
+ # fix that raised "TypeError: wrong argument type nil (expected Coolio::Loop)"
28
+ # and could crash the VM.
29
+ #
30
+ # This is exercised deterministically within a single thread (a preceding
31
+ # callback detaching another watcher in the same loop cycle). The original
32
+ # reproduction detached from a separate thread while the loop was polling,
33
+ # which is an unsupported concurrent mutation of libev (not thread-safe) and
34
+ # crashed intermittently on macOS.
35
+ it "does not raise when a watcher with a pending event is detached during dispatch" do
22
36
  iterations = 200
37
+ # Winsock select monitors sockets, not IO.pipe handles. TCP connections
38
+ # exercise the same queued-event scenario on every supported platform.
39
+ listener = TCPServer.new('127.0.0.1', 0)
23
40
 
24
- expect {
25
- iterations.times do
26
- r_victim, w_victim = IO.pipe
27
- victim_watcher = Victim.new(r_victim)
28
- victim_watcher.attach(loop)
41
+ iterations.times do
42
+ coolio_loop = Cool.io::Loop.new
43
+ connections = []
44
+ watchers = []
29
45
 
30
- t1 = Thread.new do
31
- sleep 0.01
32
- w_victim.write("dummy\n")
46
+ begin
47
+ 5.times do
48
+ w = TCPSocket.new('127.0.0.1', listener.addr[1])
49
+ connections << [nil, w]
50
+ r = listener.accept
51
+ connections.last[0] = r
52
+
53
+ watcher = Victim.new(r) do |fired|
54
+ # Detach every other watcher whose event is already queued for this
55
+ # same loop iteration.
56
+ watchers.each do |other|
57
+ other.detach if !other.equal?(fired) && other.attached?
58
+ end
59
+ end
60
+ watcher.attach(coolio_loop)
61
+ watchers << watcher
62
+
63
+ w.write("dummy\n") # make the read end readable so an event is pending
33
64
  end
34
65
 
35
- t2 = Thread.new do
36
- sleep 0.01
37
- victim_watcher.detach
66
+ # TCP delivery is asynchronous. Ensure all five read ends are ready
67
+ # before libev collects the events, without consuming their data.
68
+ connections.each do |r, _|
69
+ expect(IO.select([r], nil, nil, 2)).not_to be_nil
38
70
  end
39
-
40
- loop.run_once
41
-
42
- t1.join
43
- t2.join
44
-
45
- r_victim.close
46
- w_victim.close
71
+ coolio_loop.run_once
72
+
73
+ # Only the first dispatched watcher runs; it detaches the other four,
74
+ # whose pending events are then skipped.
75
+ expect(watchers.count(&:attached?)).to eq(1)
76
+ ensure
77
+ watchers.each { |watcher| watcher.detach if watcher.attached? }
78
+ connections.each { |r, w| r.close if r; w.close }
47
79
  end
48
- }.not_to raise_error
80
+ end
81
+ ensure
82
+ listener.close if listener
49
83
  end
50
84
 
51
85
  class HttpHandler < Coolio::IO
data/spec/dns_spec.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require File.expand_path('../spec_helper', __FILE__)
2
+ require 'tempfile'
2
3
 
3
4
  VALID_DOMAIN = "google.com"
4
5
  INVALID_DOMAIN = "gibidigibigididibitidibigitibidigitidididi.com"
@@ -55,4 +56,200 @@ describe "DNS" do
55
56
  expect( Coolio::DNSResolver.hosts("localhost", file.path)).to eq @preferred_localhost_address
56
57
  end
57
58
  end
59
+
60
+ describe "IPv6 nameserver filtering" do
61
+ it "ignores IPv6 nameservers provided in arguments" do
62
+ resolver = Coolio::DNSResolver.new("example.com", "8.8.8.8", "2001:4860:4860::8888", "1.1.1.1")
63
+
64
+ nameservers = resolver.instance_variable_get(:@nameservers)
65
+ expect(nameservers).to eq(["8.8.8.8", "1.1.1.1"])
66
+ end
67
+
68
+ it "falls back to default IPv4 config if only IPv6 addresses are provided" do
69
+ allow(Resolv::DNS::Config).to receive(:default_config_hash).and_return({
70
+ nameserver: ["8.8.4.4", "2001:4860:4860::8844"]
71
+ })
72
+
73
+ resolver = Coolio::DNSResolver.new("example.com", "2001:4860:4860::8888")
74
+
75
+ nameservers = resolver.instance_variable_get(:@nameservers)
76
+ expect(nameservers).to eq(["8.8.4.4"])
77
+ end
78
+ end
79
+
80
+ describe "nameserver normalization" do
81
+ let(:localhost_address) do
82
+ Addrinfo.getaddrinfo("localhost", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first.ip_address
83
+ end
84
+
85
+ it "keeps the nameserver list as given" do
86
+ resolver = Coolio::DNSResolver.new("example.com", "localhost")
87
+
88
+ expect(resolver.instance_variable_get(:@nameservers)).to eq(["localhost"])
89
+ end
90
+
91
+ it "queries the numeric address of a nameserver given as a hostname" do
92
+ resolver = Coolio::DNSResolver.new("example.com", "localhost")
93
+ resolver.__send__(:send_request)
94
+
95
+ expect(resolver.instance_variable_get(:@queried_addresses)).to eq([localhost_address])
96
+ end
97
+
98
+ it "accepts responses from a nameserver which was given as a hostname" do
99
+ resolver = Coolio::DNSResolver.new("example.com", "localhost")
100
+ resolver.__send__(:send_request)
101
+ response = dns_response_for(resolver)
102
+
103
+ expect(
104
+ resolver.__send__(:solicited_response?, response, ["AF_INET", 53, localhost_address, localhost_address])
105
+ ).to be true
106
+ end
107
+
108
+ it "looks a nameserver up once and reuses the result on retries" do
109
+ resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
110
+ resolver = Coolio::DNSResolver.new("example.com", "127.0.0.1")
111
+
112
+ expect(Addrinfo).to receive(:getaddrinfo).once.and_return(resolved)
113
+
114
+ 3.times { resolver.__send__(:send_request) }
115
+ end
116
+
117
+ it "does not reject an unresolvable nameserver at construction" do
118
+ allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
119
+
120
+ expect do
121
+ Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
122
+ end.to_not raise_error
123
+ end
124
+
125
+ it "surfaces an unresolvable nameserver as a SocketError from the request" do
126
+ allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
127
+ resolver = Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
128
+
129
+ expect { resolver.attach(@loop) }.to raise_error(SocketError)
130
+ expect(@loop.watchers).to be_empty
131
+ end
132
+
133
+ it "recovers when a nameserver is only transiently unresolvable" do
134
+ resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
135
+ resolver = Coolio::DNSResolver.new("example.com", "ns.example.test")
136
+
137
+ attempts = 0
138
+ allow(Addrinfo).to receive(:getaddrinfo) do
139
+ attempts += 1
140
+ raise SocketError, "getaddrinfo: Name or service not known" if attempts == 1
141
+
142
+ resolved
143
+ end
144
+
145
+ expect { resolver.__send__(:send_request) }.to raise_error(SocketError)
146
+ expect { resolver.__send__(:send_request) }.to_not raise_error
147
+ expect(resolver.instance_variable_get(:@queried_addresses)).to eq(["127.0.0.1"])
148
+ end
149
+ end
150
+
151
+ describe "response validation" do
152
+ let(:nameserver) { "127.0.0.1" }
153
+ let(:sender) { ["AF_INET", 53, nameserver, nameserver] }
154
+ let(:resolver) do
155
+ Coolio::DNSResolver.new("example.com", nameserver).tap { |r| r.__send__(:send_request) }
156
+ end
157
+
158
+ it "uses an unpredictable transaction ID for each query" do
159
+ ids = 10.times.map do
160
+ request_id_of(Coolio::DNSResolver.new("example.com", nameserver))
161
+ end
162
+
163
+ expect(ids.uniq.size).to be > 1
164
+ end
165
+
166
+ it "accepts a response carrying our transaction ID from the queried nameserver" do
167
+ expect(
168
+ resolver.__send__(:solicited_response?, dns_response_for(resolver), sender)
169
+ ).to be true
170
+ end
171
+
172
+ it "rejects a response carrying a different transaction ID" do
173
+ forged = dns_response_for(resolver, id: (request_id_of(resolver) + 1) % 65536)
174
+
175
+ expect(resolver.__send__(:solicited_response?, forged, sender)).to be false
176
+ end
177
+
178
+ it "rejects a response from a source address we did not query" do
179
+ response = dns_response_for(resolver)
180
+
181
+ expect(
182
+ resolver.__send__(:solicited_response?, response, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"])
183
+ ).to be false
184
+ end
185
+
186
+ it "rejects a response arriving before the request was sent" do
187
+ unsent = Coolio::DNSResolver.new("example.com", nameserver)
188
+
189
+ expect(unsent.__send__(:solicited_response?, dns_response_for(unsent), sender)).to be false
190
+ end
191
+
192
+ it "rejects a response from a source port other than the DNS port" do
193
+ response = dns_response_for(resolver)
194
+
195
+ expect(
196
+ resolver.__send__(:solicited_response?, response, ["AF_INET", 4444, nameserver, nameserver])
197
+ ).to be false
198
+ end
199
+
200
+ it "rejects a truncated datagram" do
201
+ expect(resolver.__send__(:solicited_response?, "\0\0", sender)).to be false
202
+ end
203
+
204
+ it "resolves from a response sent by the queried nameserver" do
205
+ response = dns_response_for(resolver, address: "1.2.3.4")
206
+ allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([response, sender])
207
+
208
+ expect(resolver).to receive(:on_success).with("1.2.3.4")
209
+ expect(resolver).to receive(:detach)
210
+
211
+ resolver.__send__(:on_readable)
212
+ end
213
+
214
+ it "fails a response whose A record is shorter than its RDLENGTH instead of resolving a bogus address" do
215
+ truncated = dns_response_for(resolver, address: "1.2.3.4")[0...-4]
216
+ allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([truncated, sender])
217
+
218
+ expect(resolver).to_not receive(:on_success)
219
+ expect(resolver).to receive(:on_failure)
220
+ expect(resolver).to receive(:detach)
221
+
222
+ resolver.__send__(:on_readable)
223
+ end
224
+
225
+ it "ignores a spoofed response instead of resolving or failing it" do
226
+ forged = dns_response_for(resolver, address: "6.6.6.6")
227
+ allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock)
228
+ .and_return([forged, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"]])
229
+
230
+ expect(resolver).to_not receive(:on_success)
231
+ expect(resolver).to_not receive(:on_failure)
232
+ expect(resolver).to_not receive(:detach)
233
+
234
+ resolver.__send__(:on_readable)
235
+ end
236
+ end
237
+
238
+ def request_id_of(resolver)
239
+ resolver.__send__(:request_message)[0..1].unpack('n').first
240
+ end
241
+
242
+ # A response to the resolver's own query: header plus the echoed question,
243
+ # and an A record when an address is given.
244
+ def dns_response_for(resolver, id: request_id_of(resolver), address: nil)
245
+ question = resolver.instance_variable_get(:@question)
246
+ answer = if address
247
+ # Compressed name pointer, type A, class IN, TTL, RDLENGTH, RDATA
248
+ [0xc00c, 1, 1, 60, 4].pack('nnnNn') + address.split('.').map(&:to_i).pack('CCCC')
249
+ else
250
+ ""
251
+ end
252
+
253
+ [id, 0x81, 0x80, 1, answer.empty? ? 0 : 1, 0, 0].pack('nCCnnnn') + question + answer
254
+ end
58
255
  end