curb 1.4.1 → 1.5.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.
@@ -799,6 +799,7 @@ class TestCurbCurlMulti < Test::Unit::TestCase
799
799
  end
800
800
 
801
801
  def test_native_safety_signatures_are_pruned_after_perform
802
+ Curl.safe! { |config| config.network_policy = :none }
802
803
  m = Curl::Multi.new
803
804
  easy = Curl::Easy.new($TEST_URL)
804
805
 
@@ -811,6 +812,37 @@ class TestCurbCurlMulti < Test::Unit::TestCase
811
812
 
812
813
  assert_equal 0, m.requests.length
813
814
  assert_empty signatures
815
+ ensure
816
+ m.close if m
817
+ Curl.__send__(:clear_safe!)
818
+ end
819
+
820
+ def test_unconstrained_easy_skips_safety_signature_bookkeeping
821
+ m = Curl::Multi.new
822
+ easy = Curl::Easy.new(TestServlet.url)
823
+
824
+ m.add(easy)
825
+ assert_empty m.__send__(:__curb_native_safety_signatures)
826
+
827
+ m.perform
828
+ assert_equal 200, easy.response_code
829
+ ensure
830
+ m.close if m
831
+ end
832
+
833
+ def test_native_policy_set_after_unconstrained_add_is_applied
834
+ m = Curl::Multi.new
835
+ easy = Curl::Easy.new(TestServlet.url)
836
+ m.add(easy)
837
+ begin
838
+ easy.network_policy = :public
839
+ rescue NotImplementedError
840
+ omit('network_policy=:public requires CURLOPT_OPENSOCKETFUNCTION support')
841
+ end
842
+
843
+ m.perform
844
+
845
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
814
846
  ensure
815
847
  m.close if m
816
848
  end
@@ -1018,43 +1018,36 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
1018
1018
  Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
1019
1019
  end
1020
1020
 
1021
- def test_public_network_policy_blocks_private_peer_after_live_dns_answer_changes
1022
- omit('live DNS rebinding test requires CURLOPT_DOH_URL') unless Curl.const_defined?(:CURLOPT_DOH_URL)
1023
- omit('live DNS rebinding test requires CURLOPT_DOH_SSL_VERIFYPEER') unless Curl.const_defined?(:CURLOPT_DOH_SSL_VERIFYPEER)
1024
- omit('live DNS rebinding test requires CURLOPT_DOH_SSL_VERIFYHOST') unless Curl.const_defined?(:CURLOPT_DOH_SSL_VERIFYHOST)
1021
+ # The peer address is vetted at connect time for every resolution, so a
1022
+ # name that resolves to an allowed address once and a private one later is
1023
+ # still blocked. (This used a loopback DoH server as the changing resolver;
1024
+ # public policy now refuses non-public DoH endpoints, so CURLOPT_RESOLVE
1025
+ # supplies the two answers instead.)
1026
+ def test_public_network_policy_blocks_private_peer_after_dns_answer_changes
1025
1027
  require_public_network_policy!
1026
1028
 
1027
- host = 'curb-doh-rebind.invalid'
1028
- with_rebinding_doh_server(host, '1.1.1.1') do |doh_url, doh_state|
1029
- Curl.safe! do |config|
1030
- config.network_policy = :public
1031
- config.allow_doh = true
1032
- config.allowed_cidrs = ['8.8.8.0/24']
1033
- end
1034
-
1035
- first = Curl::Easy.new("http://#{host}/dns-rebind-target")
1036
- configure_live_doh_easy(first, doh_url)
1037
-
1038
- first_error = assert_raise(Curl::Err::UnsafeDestinationError) do
1039
- first.perform
1040
- end
1041
-
1042
- assert_include doh_a_answers(doh_state), '1.1.1.1'
1043
- assert_match(/outside allowed CIDR ranges/, first_error.message)
1044
- assert_match(/1\.1\.1\.1/, first_error.message)
1045
-
1046
- set_doh_answer(doh_state, '127.0.0.1')
1047
- second = Curl::Easy.new("http://#{host}/dns-rebind-target")
1048
- configure_live_doh_easy(second, doh_url)
1029
+ host = 'curb-dns-rebind.invalid'
1030
+ Curl.safe! do |config|
1031
+ config.network_policy = :public
1032
+ config.allow_resolve = true
1033
+ config.allowed_cidrs = ['8.8.8.0/24']
1034
+ end
1049
1035
 
1050
- error = assert_raise(Curl::Err::UnsafeDestinationError) do
1051
- second.perform
1052
- end
1036
+ first = Curl::Easy.new("http://#{host}/dns-rebind-target")
1037
+ first.resolve = ["#{host}:80:1.1.1.1"]
1038
+ first_error = assert_raise(Curl::Err::UnsafeDestinationError) do
1039
+ first.perform
1040
+ end
1041
+ assert_match(/outside allowed CIDR ranges/, first_error.message)
1042
+ assert_match(/1\.1\.1\.1/, first_error.message)
1053
1043
 
1054
- assert_include doh_a_answers(doh_state), '127.0.0.1'
1055
- assert_match(/public network policy/, error.message)
1056
- assert_match(/127\.0\.0\.1/, error.message)
1044
+ second = Curl::Easy.new("http://#{host}/dns-rebind-target")
1045
+ second.resolve = ["#{host}:80:127.0.0.1"]
1046
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
1047
+ second.perform
1057
1048
  end
1049
+ assert_match(/public network policy/, error.message)
1050
+ assert_match(/127\.0\.0\.1/, error.message)
1058
1051
  ensure
1059
1052
  Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
1060
1053
  end
@@ -0,0 +1,386 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ # Regression coverage for request-hardening fixes: non-IP sockets and DoH
4
+ # endpoints under network_policy = :public, file:// under :public, cookies=
5
+ # on cross-host redirects, redirect/timeout defaults, and CR/LF injection.
6
+ class TestCurbCurlSecurityHardening < Test::Unit::TestCase
7
+ include TestServerMethods
8
+
9
+ def setup
10
+ server_setup
11
+ @servers = []
12
+ end
13
+
14
+ def teardown
15
+ @servers.each(&:close)
16
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
17
+ super
18
+ end
19
+
20
+ # Starts a server on 127.0.0.1 that answers each request with the result of
21
+ # the block (given the request path) and records [path, request headers].
22
+ def scripted_server(&respond)
23
+ server = TCPServer.new('127.0.0.1', 0)
24
+ @servers << server
25
+ log = Queue.new
26
+ Thread.new do
27
+ loop do
28
+ client = begin
29
+ server.accept
30
+ rescue IOError, Errno::EBADF
31
+ break
32
+ end
33
+ begin
34
+ request = +''
35
+ request << client.readpartial(4096) until request.include?("\r\n\r\n")
36
+ path = request[/\A\S+ (\S+)/, 1]
37
+ log << [path, request.split("\r\n\r\n", 2).first.lines.drop(1).map(&:strip)]
38
+ client.write(respond.call(path))
39
+ rescue IOError, SystemCallError
40
+ nil
41
+ ensure
42
+ client.close
43
+ end
44
+ end
45
+ end
46
+ [server.addr[1], log]
47
+ end
48
+
49
+ def ok_response(body = 'ok')
50
+ "HTTP/1.1 200 OK\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
51
+ end
52
+
53
+ def redirect_response(location)
54
+ "HTTP/1.1 302 Found\r\nLocation: #{location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
55
+ end
56
+
57
+ def drain(log)
58
+ entries = []
59
+ entries << log.pop until log.empty?
60
+ entries
61
+ end
62
+
63
+ def require_public_network_policy!
64
+ Curl::Easy.new.network_policy = :public
65
+ rescue NotImplementedError
66
+ omit('network_policy=:public requires CURLOPT_OPENSOCKETFUNCTION support')
67
+ end
68
+
69
+ # --- non-IP sockets under :public ----------------------------------------
70
+
71
+ def test_unix_socket_set_after_multi_add_is_blocked_by_public_policy
72
+ omit('CURLOPT_UNIX_SOCKET_PATH not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_UNIX_SOCKET_PATH)
73
+ omit('Unix domain sockets are not available on this platform') if WINDOWS || !defined?(UNIXServer)
74
+ require_public_network_policy!
75
+
76
+ dir = Dir.mktmpdir('curb-unix')
77
+ path = File.join(dir, 's.sock')
78
+ server = UNIXServer.new(path)
79
+ accepted = Queue.new
80
+ thread = Thread.new do
81
+ socket = server.accept
82
+ accepted << true
83
+ socket.readpartial(4096) rescue nil
84
+ socket.write(ok_response('bypass'))
85
+ socket.close
86
+ end
87
+
88
+ easy = Curl::Easy.new('http://198.51.100.1/')
89
+ easy.network_policy = :public
90
+ multi = Curl::Multi.new
91
+ multi.add(easy)
92
+ easy.setopt(Curl::CURLOPT_UNIX_SOCKET_PATH, path)
93
+ multi.perform
94
+
95
+ assert_not_equal 'bypass', easy.body_str
96
+ assert_match(/non-IP socket/, easy.unsafe_destination_error)
97
+ assert accepted.empty?, 'the Unix socket server must not be contacted'
98
+ ensure
99
+ multi.close if multi
100
+ thread.kill if thread
101
+ server.close if server
102
+ FileUtils.rm_rf(dir) if dir
103
+ end
104
+
105
+ # --- DoH endpoints under :public ------------------------------------------
106
+
107
+ def test_public_policy_requires_public_ip_literal_doh_endpoints
108
+ omit('CURLOPT_DOH_URL not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_URL)
109
+ require_public_network_policy!
110
+
111
+ blocked = %w[
112
+ https://localhost/dns-query https://dns.example/dns-query
113
+ https://127.0.0.1/dns-query https://127.1/dns-query https://2130706433/dns-query
114
+ https://[::1]/dns-query https://[::ffff:127.0.0.1]/dns-query https://10.0.0.1/dns-query
115
+ ]
116
+ blocked.each do |doh_url|
117
+ easy = Curl::Easy.new('http://198.51.100.1/')
118
+ easy.network_policy = :public
119
+ easy.doh_url = doh_url
120
+ assert_raise(Curl::Err::UnsafeDestinationError, doh_url) { easy.__send__(:__curb_native_setup!) }
121
+ end
122
+
123
+ %w[https://1.1.1.1/dns-query https://[2606:4700:4700::1111]/dns-query].each do |doh_url|
124
+ easy = Curl::Easy.new('http://198.51.100.1/')
125
+ easy.network_policy = :public
126
+ easy.doh_url = doh_url
127
+ assert_nothing_raised(doh_url) { easy.__send__(:__curb_native_setup!) }
128
+ end
129
+ end
130
+
131
+ def test_safe_mode_allow_doh_still_rejects_local_doh_endpoint
132
+ omit('CURLOPT_DOH_URL not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_URL)
133
+ require_public_network_policy!
134
+ port, log = scripted_server { |_| ok_response }
135
+
136
+ Curl.safe! { |config| config.allow_doh = true }
137
+ easy = Curl::Easy.new('http://example.com/')
138
+ easy.doh_url = "https://localhost:#{port}/dns-query"
139
+
140
+ error = assert_raise(Curl::Err::UnsafeDestinationError) { easy.perform }
141
+ assert_match(/DoH URL must use a public IP address/, error.message)
142
+ assert_empty drain(log)
143
+ end
144
+
145
+ def test_public_policy_doh_check_does_not_apply_without_policy
146
+ omit('CURLOPT_DOH_URL not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_URL)
147
+ easy = Curl::Easy.new('http://198.51.100.1/')
148
+ easy.doh_url = 'https://localhost/dns-query'
149
+ assert_nothing_raised { easy.__send__(:__curb_native_setup!) }
150
+ end
151
+
152
+ # --- file:// under :public ------------------------------------------------
153
+
154
+ def test_public_policy_rejects_file_urls_without_protocol_allowlist
155
+ require_public_network_policy!
156
+ ['file:///etc/hosts', 'FILE:///etc/hosts', 'file:/etc/hosts', "file://#{File.expand_path(__FILE__)}"].each do |url|
157
+ easy = Curl::Easy.new(url)
158
+ easy.network_policy = :public
159
+ error = assert_raise(Curl::Err::UnsafeDestinationError, url) { easy.perform }
160
+ assert_match(/file:\/\/ URLs are not allowed/, error.message)
161
+ end
162
+ end
163
+
164
+ def test_public_policy_respects_explicit_file_protocol_allowlist
165
+ require_public_network_policy!
166
+ easy = Curl::Easy.new("file://#{File.expand_path(__FILE__)}")
167
+ easy.network_policy = :public
168
+ easy.allowed_protocols = [:file]
169
+ easy.perform
170
+ assert_match(/Regression coverage for request-hardening/, easy.body_str)
171
+ end
172
+
173
+ def test_file_urls_still_work_without_public_policy
174
+ easy = Curl::Easy.new($TEST_URL)
175
+ easy.perform
176
+ assert_match(/DO NOT REMOVE THIS COMMENT/, easy.body_str)
177
+ end
178
+
179
+ # --- cookies= on redirects ------------------------------------------------
180
+
181
+ def test_cookies_are_not_forwarded_to_a_different_host_on_redirect
182
+ other_port, other_log = scripted_server { |_| ok_response }
183
+ port, log = scripted_server { |_| redirect_response("http://127.0.0.1:#{other_port}/landing") }
184
+
185
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/start")
186
+ easy.follow_location = true
187
+ easy.cookies = 'session=secret'
188
+ easy.perform
189
+
190
+ assert_equal 200, easy.response_code
191
+ first = drain(log)
192
+ second = drain(other_log)
193
+ assert_include first.first.last, 'Cookie: session=secret'
194
+ assert_equal 1, second.length
195
+ assert_empty second.first.last.grep(/\Acookie:/i), 'cookies= must not reach a different host'
196
+ end
197
+
198
+ def test_cookies_are_kept_on_same_host_redirect
199
+ port, log = scripted_server { |path| path == '/start' ? redirect_response('/landing') : ok_response }
200
+
201
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/start")
202
+ easy.follow_location = true
203
+ easy.cookies = 'session=secret'
204
+ easy.perform
205
+
206
+ requests = drain(log)
207
+ assert_equal ['/start', '/landing'], requests.map(&:first)
208
+ requests.each { |(_, headers)| assert_equal ['Cookie: session=secret'], headers.grep(/\Acookie:/i) }
209
+ end
210
+
211
+ def test_explicit_cookie_header_still_wins_over_cookies
212
+ port, log = scripted_server { |_| ok_response }
213
+
214
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/")
215
+ easy.headers['Cookie'] = 'explicit=1'
216
+ easy.cookies = 'session=secret'
217
+ easy.perform
218
+
219
+ assert_equal ['Cookie: explicit=1'], drain(log).first.last.grep(/\Acookie:/i)
220
+ end
221
+
222
+ def test_cookies_cleared_after_being_set_are_not_sent
223
+ port, log = scripted_server { |_| ok_response }
224
+
225
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/")
226
+ easy.cookies = 'session=secret'
227
+ easy.perform
228
+ easy.cookies = nil
229
+ easy.perform
230
+
231
+ first, second = drain(log)
232
+ assert_equal ['Cookie: session=secret'], first.last.grep(/\Acookie:/i)
233
+ assert_empty second.last.grep(/\Acookie:/i)
234
+ end
235
+
236
+ def test_cookies_merge_with_cookie_engine_on_one_line
237
+ port, log = scripted_server do |path|
238
+ if path == '/start'
239
+ "HTTP/1.1 302 Found\r\nLocation: /landing\r\nSet-Cookie: fromserver=1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
240
+ else
241
+ ok_response
242
+ end
243
+ end
244
+
245
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/start")
246
+ easy.follow_location = true
247
+ easy.enable_cookies = true
248
+ easy.cookies = 'session=secret'
249
+ easy.perform
250
+
251
+ landing = drain(log).last.last.grep(/\Acookie:/i)
252
+ assert_equal 1, landing.length, 'the cookie engine and cookies= must share one Cookie line'
253
+ assert_match(/fromserver=1/, landing.first)
254
+ assert_match(/session=secret/, landing.first)
255
+ end
256
+
257
+ # --- redirect and timeout defaults ----------------------------------------
258
+
259
+ def test_unset_max_redirects_caps_redirect_loops_at_thirty
260
+ port, log = scripted_server { |path| redirect_response("/r#{path[/\d+/].to_i + 1}") }
261
+
262
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/r0")
263
+ easy.follow_location = true
264
+ assert_nil easy.max_redirects
265
+ assert_raise(Curl::Err::TooManyRedirectsError) { easy.perform }
266
+ assert_equal 31, drain(log).length
267
+ end
268
+
269
+ def test_explicit_unlimited_and_explicit_max_redirects_are_honored
270
+ port, log = scripted_server { |path| (n = path[/\d+/].to_i) < 40 ? redirect_response("/r#{n + 1}") : ok_response }
271
+
272
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/r0")
273
+ easy.follow_location = true
274
+ easy.max_redirects = -1
275
+ easy.perform
276
+ assert_equal 200, easy.response_code
277
+ assert_equal 41, drain(log).length
278
+
279
+ easy.max_redirects = 2
280
+ assert_raise(Curl::Err::TooManyRedirectsError) { easy.perform }
281
+ assert_equal 3, drain(log).length
282
+ end
283
+
284
+ def test_safe_mode_applies_default_timeout_and_redirect_cap
285
+ config = Curl::SafetyConfig.new
286
+ assert_equal 60, config.timeout
287
+ assert_equal 20, config.max_redirects
288
+
289
+ Curl.safe!
290
+ easy = Curl::Easy.new('http://198.51.100.1/')
291
+ Curl.__send__(:apply_safety!, easy)
292
+ assert_equal 60.0, easy.timeout
293
+ assert_equal 20, easy.max_redirects
294
+
295
+ unlimited = Curl::Easy.new('http://198.51.100.1/')
296
+ unlimited.max_redirects = -1
297
+ Curl.__send__(:apply_safety!, unlimited)
298
+ assert_equal 20, unlimited.max_redirects
299
+ end
300
+
301
+ def test_safe_mode_keeps_handle_limits_and_accepts_overrides
302
+ Curl.safe!
303
+ own = Curl::Easy.new('http://198.51.100.1/')
304
+ own.timeout = 5
305
+ own.max_redirects = 3
306
+ Curl.__send__(:apply_safety!, own)
307
+ assert_equal 5.0, own.timeout
308
+ assert_equal 3, own.max_redirects
309
+ Curl.__send__(:clear_safe!)
310
+
311
+ helper = Curl::Easy.new('http://198.51.100.1/')
312
+ helper.safe_http!(:timeout => 7, :max_redirects => 2)
313
+ assert_equal 7.0, helper.timeout
314
+ assert_equal 2, helper.max_redirects
315
+
316
+ Curl.safe! { |c| c.timeout = nil; c.max_redirects = nil }
317
+ disabled = Curl::Easy.new('http://198.51.100.1/')
318
+ Curl.__send__(:apply_safety!, disabled)
319
+ assert_equal 0.0, disabled.timeout
320
+ assert_nil disabled.max_redirects
321
+ end
322
+
323
+ def test_safety_config_validates_limits
324
+ config = Curl::SafetyConfig.new
325
+ assert_raise(ArgumentError) { config.timeout = 0 }
326
+ assert_raise(ArgumentError) { config.timeout = -1 }
327
+ assert_raise(ArgumentError) { config.max_redirects = -1 }
328
+ config.timeout = 1.5
329
+ config.max_redirects = 0
330
+ assert_equal 1.5, config.timeout
331
+ assert_equal 0, config.max_redirects
332
+ end
333
+
334
+ # --- CR/LF/NUL injection --------------------------------------------------
335
+
336
+ INJECTIONS = {
337
+ 'header value' => ->(easy) { easy.headers['X-User'] = "alice\r\nX-Injected: yes" },
338
+ 'header name' => ->(easy) { easy.headers["X-A\r\nX-Injected"] = 'yes' },
339
+ 'header array entry' => ->(easy) { easy.headers = ["X-User: alice\nX-Injected: yes"] },
340
+ 'header string' => ->(easy) { easy.headers = "X-User: alice\r\nX-Injected: yes" },
341
+ 'header NUL' => ->(easy) { easy.headers['X-User'] = "alice\0X-Injected: yes" },
342
+ 'useragent' => ->(easy) { easy.useragent = "ua\r\nX-Injected: yes" },
343
+ 'cookies' => ->(easy) { easy.cookies = "a=1\r\nX-Injected: yes" },
344
+ }.freeze
345
+
346
+ def test_crlf_in_request_headers_is_rejected
347
+ port, log = scripted_server { |_| ok_response }
348
+ INJECTIONS.each do |name, inject|
349
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/")
350
+ inject.call(easy)
351
+ error = assert_raise(ArgumentError, name) { easy.perform }
352
+ assert_match(/must not contain CR, LF, or NUL/, error.message, name)
353
+ end
354
+ assert_empty drain(log), 'no injected request may reach the server'
355
+ end
356
+
357
+ def test_crlf_in_proxy_headers_is_rejected
358
+ omit('proxy_headers not supported') unless Curl::Easy.method_defined?(:proxy_headers=)
359
+ easy = Curl::Easy.new('http://198.51.100.1/')
360
+ easy.proxy_headers['X-Proxy'] = "a\r\nX-Injected: yes"
361
+ assert_raise(ArgumentError) { easy.__send__(:__curb_native_setup!) }
362
+ end
363
+
364
+ def test_crlf_in_request_method_and_target_is_rejected
365
+ easy = Curl::Easy.new(TestServlet.url)
366
+ assert_raise(ArgumentError) { easy.setopt(Curl::CURLOPT_CUSTOMREQUEST, "GET / HTTP/1.1\r\nX-Injected: yes\r\n\r\nGET") }
367
+ assert_raise(ArgumentError) { easy.http("GET\r\nX-Injected: yes") }
368
+ if Curl.const_defined?(:CURLOPT_REQUEST_TARGET)
369
+ assert_raise(ArgumentError) { easy.setopt(Curl::CURLOPT_REQUEST_TARGET, "/\r\nX-Injected: yes") }
370
+ end
371
+ end
372
+
373
+ def test_ordinary_headers_and_header_removal_still_work
374
+ port, log = scripted_server { |_| ok_response }
375
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/")
376
+ easy.headers['X-User'] = 'alice'
377
+ easy.headers['Accept'] = ''
378
+ easy.useragent = 'curb-test/1.0'
379
+ easy.perform
380
+
381
+ headers = drain(log).first.last
382
+ assert_include headers, 'X-User: alice'
383
+ assert_include headers, 'User-Agent: curb-test/1.0'
384
+ assert_empty headers.grep(/\AAccept:/i)
385
+ end
386
+ end
@@ -83,7 +83,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
83
83
  SERIAL_TIME_WOULD_BE_ABOUT = MIN_S * ITERS
84
84
 
85
85
  def setup
86
- @port = unused_local_port
86
+ @port = :auto
87
87
 
88
88
  @response_proc = lambda do |res|
89
89
  res['Content-Type'] = 'text/plain'
@@ -650,6 +650,48 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
650
650
  end
651
651
  end
652
652
 
653
+ # The scheduler driver no longer yields on every Multi#perform pass when the
654
+ # socket-action loop is in use; sibling fibers must still run while a large
655
+ # body streams in.
656
+ def test_sibling_fibers_run_while_easy_perform_streams_large_body
657
+ if skip_no_async
658
+ return
659
+ end
660
+
661
+ server = CurbTinyHTTPServer.new
662
+ expected = CurbTinyHTTPServer.stream_body(256, 65536)
663
+ ticks = 0
664
+ ticks_during_transfer = nil
665
+ body = nil
666
+
667
+ async_run do |top|
668
+ done = false
669
+ ticker = top.async do
670
+ until done
671
+ ticks += 1
672
+ sleep 0.001
673
+ end
674
+ end
675
+
676
+ top.async do
677
+ start_ticks = ticks
678
+ easy = Curl::Easy.new(server.url('/stream/256/65536'))
679
+ easy.perform
680
+ ticks_during_transfer = ticks - start_ticks
681
+ body = easy.body_str
682
+ ensure
683
+ done = true
684
+ end.wait
685
+ ticker.wait
686
+ end
687
+
688
+ assert_equal expected.bytesize, body.bytesize
689
+ assert_equal expected, body
690
+ assert_operator ticks_during_transfer, :>=, 2, 'sibling fiber should run while the transfer streams'
691
+ ensure
692
+ server.close if server
693
+ end
694
+
653
695
  def test_easy_perform_keeps_scheduler_timers_running_while_draining_deferred_on_complete_exception
654
696
  if skip_no_async
655
697
  return
@@ -981,12 +1023,10 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
981
1023
  end
982
1024
 
983
1025
  def with_ephemeral_http_server
984
- port_socket = TCPServer.new('127.0.0.1', 0)
985
- port = port_socket.addr[1]
986
- port_socket.close
987
-
988
1026
  hits = Hash.new(0)
989
- server = WEBrick::HTTPServer.new(:Port => port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
1027
+ server = WEBrick::HTTPServer.new(:Port => 0, :BindAddress => '127.0.0.1',
1028
+ :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
1029
+ port = server.config[:Port]
990
1030
  server.mount_proc('/fast') do |_req, res|
991
1031
  hits[:fast] += 1
992
1032
  res['Content-Type'] = 'text/plain'
data/tests/tc_ractor.rb CHANGED
@@ -102,7 +102,11 @@ class TestCurbRactor < Test::Unit::TestCase
102
102
  # single-Ractor compaction is covered by the PostField tests.
103
103
  if iteration % 10 == 0 && !WINDOWS
104
104
  GC.start
105
- GC.compact if GC.respond_to?(:compact)
105
+ # Ruby 3.4 can crash in method/constant lookup when another
106
+ # Ractor compacts the heap, even with plain TCPSocket requests
107
+ # and no curb loaded. Keep ordinary GC and parallel mutation
108
+ # here; tc_curl_postfield covers compaction without Ractors.
109
+ GC.compact if GC.respond_to?(:compact) && !RUBY_VERSION.start_with?('3.4.')
106
110
  end
107
111
  'done'
108
112
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: curb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ross Bamford
8
8
  - Todd A. Fisher
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Curb (probably CUrl-RuBy or something) provides Ruby-language bindings
14
14
  for the libcurl(3), a fully-featured client-side URL transfer library. cURL and
@@ -85,6 +85,7 @@ files:
85
85
  - tests/tc_curl_network_policy.rb
86
86
  - tests/tc_curl_postfield.rb
87
87
  - tests/tc_curl_protocols.rb
88
+ - tests/tc_curl_security_hardening.rb
88
89
  - tests/tc_curl_transfer_guard.rb
89
90
  - tests/tc_fiber_scheduler.rb
90
91
  - tests/tc_ftp_options.rb
@@ -159,6 +160,7 @@ test_files:
159
160
  - tests/tc_curl_network_policy.rb
160
161
  - tests/tc_curl_postfield.rb
161
162
  - tests/tc_curl_protocols.rb
163
+ - tests/tc_curl_security_hardening.rb
162
164
  - tests/tc_curl_transfer_guard.rb
163
165
  - tests/tc_fiber_scheduler.rb
164
166
  - tests/tc_ftp_options.rb