curb 1.4.0 → 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.
@@ -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
@@ -0,0 +1,112 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ class TestCurbTransferGuard < Test::Unit::TestCase
4
+ class InterruptedTransfer < StandardError; end
5
+
6
+ [:perform, :_socket_perform].each do |drive|
7
+ define_method("test_#{drive}_rejects_cross_thread_handle_access") do
8
+ with_waiting_transfer(drive) do |easy, multi, worker, release|
9
+ operations = {
10
+ close: -> { easy.close },
11
+ reset: -> { easy.reset },
12
+ initialize: -> { easy.__send__(:initialize) },
13
+ clone: -> { easy.clone },
14
+ dup: -> { easy.dup },
15
+ setopt: -> { easy.setopt(Curl::CURLOPT_CUSTOMREQUEST, 'DELETE') },
16
+ post_body: -> { easy.post_body = 'replacement' },
17
+ url: -> { easy.url = 'http://127.0.0.1/' },
18
+ timeout: -> { easy.timeout = 1 },
19
+ multi_assignment: -> { easy.multi = nil },
20
+ multi_remove: -> { multi.remove(easy) },
21
+ multi_close: -> { multi.close },
22
+ multi_initialize: -> { multi.__send__(:initialize) },
23
+ multi_add: -> { multi.add(Curl::Easy.new('http://127.0.0.1/')) },
24
+ multi_option: -> { multi.max_connects = 1 }
25
+ }
26
+ operations.each do |name, operation|
27
+ error = assert_raise(RuntimeError, name.to_s, &operation)
28
+ assert_match(/another thread|active Curl::Multi/, error.message, name.to_s)
29
+ end
30
+ assert_equal [easy], multi.requests.values
31
+
32
+ release << true
33
+ assert worker.join(5), 'perform did not finish'
34
+ worker.value
35
+ assert_equal 'ok', easy.body_str
36
+ assert_nothing_raised { easy.clone.close }
37
+ assert_nothing_raised { easy.reset }
38
+ assert_nothing_raised { easy.close }
39
+ end
40
+ end
41
+
42
+ define_method("test_#{drive}_releases_guard_after_interruption") do
43
+ with_waiting_transfer(drive) do |easy, multi, worker, release|
44
+ worker.raise(InterruptedTransfer, 'stop')
45
+ assert worker.join(5), 'interrupted perform did not finish'
46
+ assert_kind_of InterruptedTransfer, worker[:transfer_error]
47
+ assert_nothing_raised { multi.remove(easy) }
48
+ assert_nothing_raised { easy.reset }
49
+ assert_nothing_raised { easy.close }
50
+ release << true
51
+ end
52
+ end
53
+ end
54
+
55
+ def with_waiting_transfer(drive)
56
+ if drive == :_socket_perform && !Curl::Multi.private_method_defined?(drive)
57
+ omit('socket-action perform path is not available')
58
+ end
59
+ server = TCPServer.new('127.0.0.1', 0)
60
+ started = Queue.new
61
+ release = Queue.new
62
+ server_thread = Thread.new do
63
+ socket = server.accept
64
+ begin
65
+ request = +''
66
+ request << socket.readpartial(4096) until request.include?("\r\n\r\n")
67
+ started << true
68
+ release.pop
69
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
70
+ rescue Errno::EPIPE, Errno::ECONNRESET
71
+ # The interruption test intentionally closes the connection early.
72
+ ensure
73
+ socket.close
74
+ end
75
+ end
76
+ easy = Curl::Easy.new("http://127.0.0.1:#{server.addr[1]}/")
77
+ multi = Curl::Multi.new
78
+ # Exercise reuse after autoclose as well as the active-transfer checks.
79
+ previous_autoclose = Curl::Multi.autoclose
80
+ begin
81
+ Curl::Multi.autoclose = true
82
+ multi.add(Curl::Easy.new($TEST_URL))
83
+ multi.perform
84
+ ensure
85
+ Curl::Multi.autoclose = previous_autoclose
86
+ end
87
+ multi.add(easy)
88
+ worker = Thread.new do
89
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
90
+ begin
91
+ multi.__send__(drive)
92
+ rescue InterruptedTransfer => error
93
+ # Keep join non-raising so the test can inspect the exception explicitly.
94
+ Thread.current[:transfer_error] = error
95
+ end
96
+ end
97
+ Timeout.timeout(5) { started.pop }
98
+ yield easy, multi, worker, release
99
+ ensure
100
+ release << true if release
101
+ if worker && worker.alive?
102
+ worker.kill
103
+ worker.join(5)
104
+ end
105
+ multi.close if multi
106
+ server.close if server && !server.closed?
107
+ if server_thread
108
+ server_thread.join(5)
109
+ server_thread.kill if server_thread.alive?
110
+ end
111
+ end
112
+ 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
@@ -30,7 +30,10 @@ class TestCurbRactor < Test::Unit::TestCase
30
30
 
31
31
  workers = RACTOR_WORKERS.times.map do |worker_id|
32
32
  Ractor.new(url, worker_id, REQUESTS_PER_WORKER) do |target, id, request_count|
33
- Curl.safe! { |config| config.protocols = [:http] }
33
+ Curl.safe! do |config|
34
+ config.protocols = [:http]
35
+ config.network_policy = :none # This concurrency fixture uses localhost.
36
+ end
34
37
  Curl::Multi.default_timeout = 25 + id
35
38
  Curl::Multi.autoclose = true
36
39
 
@@ -76,6 +79,54 @@ class TestCurbRactor < Test::Unit::TestCase
76
79
  Curl::Multi.autoclose = original_autoclose if defined?(original_autoclose)
77
80
  end
78
81
 
82
+ def test_multipart_snapshots_are_private_to_parallel_ractors
83
+ omit_unless_curb_ractor_safe
84
+ worker_count = Integer(ENV.fetch('CURB_MULTIPART_RACTORS', '4'))
85
+ request_count = Integer(ENV.fetch('CURB_MULTIPART_REQUESTS', '5'))
86
+ workers = worker_count.times.map do |worker_id|
87
+ Ractor.new(TestServlet.url, worker_id, request_count) do |url, id, count|
88
+ easy = Curl::Easy.new(url)
89
+ easy.multipart_form_post = true
90
+ count.times do |iteration|
91
+ name = +"field_#{id}_#{iteration}"
92
+ payload = "payload_#{id}_#{iteration}\0" + ('x' * 1024)
93
+ expected_name = name.dup
94
+ expected_payload = payload.dup
95
+ direct = Curl::PostField.content(name, payload, 'application/octet-stream')
96
+ file = Curl::PostField.file('upload', 'payload.bin') { payload }
97
+ later = Curl::PostField.content('later') do
98
+ name.replace('changed' * 1000)
99
+ payload.replace('changed' * 1000)
100
+ # Ruby #21537: explicit GC in a Windows Ractor can deadlock
101
+ # the VM barrier. Keep parallel mutation coverage on Windows;
102
+ # single-Ractor compaction is covered by the PostField tests.
103
+ if iteration % 10 == 0 && !WINDOWS
104
+ GC.start
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.')
110
+ end
111
+ 'done'
112
+ end
113
+ easy.http_post(direct, file, later)
114
+ body = easy.body_str
115
+ unless easy.response_code == 200 &&
116
+ body.include?("name=\"#{expected_name}\"") &&
117
+ body.include?('filename="payload.bin"') &&
118
+ body.scan(expected_payload).length == 2 &&
119
+ !body.include?('changed')
120
+ raise "Multipart snapshot changed in Ractor #{id}, request #{iteration}"
121
+ end
122
+ end
123
+ easy.close
124
+ count
125
+ end
126
+ end
127
+ assert_equal Array.new(worker_count, request_count), workers.map { |worker| ractor_value(worker) }
128
+ end
129
+
79
130
  private
80
131
 
81
132
  def omit_unless_curb_ractor_safe
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.0
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-09-27 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
@@ -21,6 +21,7 @@ extra_rdoc_files:
21
21
  - LICENSE
22
22
  - README.md
23
23
  files:
24
+ - ChangeLog.md
24
25
  - LICENSE
25
26
  - README.md
26
27
  - Rakefile
@@ -84,6 +85,8 @@ files:
84
85
  - tests/tc_curl_network_policy.rb
85
86
  - tests/tc_curl_postfield.rb
86
87
  - tests/tc_curl_protocols.rb
88
+ - tests/tc_curl_security_hardening.rb
89
+ - tests/tc_curl_transfer_guard.rb
87
90
  - tests/tc_fiber_scheduler.rb
88
91
  - tests/tc_ftp_options.rb
89
92
  - tests/tc_gc_compact.rb
@@ -157,6 +160,8 @@ test_files:
157
160
  - tests/tc_curl_network_policy.rb
158
161
  - tests/tc_curl_postfield.rb
159
162
  - tests/tc_curl_protocols.rb
163
+ - tests/tc_curl_security_hardening.rb
164
+ - tests/tc_curl_transfer_guard.rb
160
165
  - tests/tc_fiber_scheduler.rb
161
166
  - tests/tc_ftp_options.rb
162
167
  - tests/tc_gc_compact.rb