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.
- checksums.yaml +4 -4
- data/ChangeLog.md +447 -0
- data/README.md +39 -8
- data/ext/curb.h +3 -3
- data/ext/curb_easy.c +567 -186
- data/ext/curb_easy.h +10 -0
- data/ext/curb_macros.h +16 -0
- data/ext/curb_multi.c +83 -23
- data/ext/curb_multi.h +4 -0
- data/ext/curb_postfield.c +103 -114
- data/ext/curb_postfield.h +10 -1
- data/ext/extconf.rb +1 -0
- data/lib/curl/easy.rb +24 -4
- data/lib/curl/multi.rb +31 -9
- data/lib/curl.rb +78 -15
- data/tests/bug_curb_easy_blocks_ruby_threads.rb +1 -1
- data/tests/bug_follow_redirect_288.rb +1 -1
- data/tests/bug_poison.rb +1 -1
- data/tests/bug_raise_on_callback.rb +1 -1
- data/tests/helper.rb +9 -8
- data/tests/tc_curl_easy.rb +304 -0
- data/tests/tc_curl_easy_cookielist.rb +10 -8
- data/tests/tc_curl_maxfilesize.rb +109 -5
- data/tests/tc_curl_multi.rb +32 -0
- data/tests/tc_curl_native_coverage.rb +57 -0
- data/tests/tc_curl_network_policy.rb +133 -33
- data/tests/tc_curl_postfield.rb +137 -0
- data/tests/tc_curl_protocols.rb +19 -7
- data/tests/tc_curl_security_hardening.rb +386 -0
- data/tests/tc_curl_transfer_guard.rb +112 -0
- data/tests/tc_fiber_scheduler.rb +46 -6
- data/tests/tc_ractor.rb +52 -1
- metadata +7 -2
data/ext/extconf.rb
CHANGED
|
@@ -699,6 +699,7 @@ have_header('ruby/thread.h') && have_func('rb_thread_call_without_gvl', 'ruby/th
|
|
|
699
699
|
have_header('ruby/io.h')
|
|
700
700
|
# Ruby 4.x exports rb_thread_fd_select without declaring it in ruby/io.h.
|
|
701
701
|
have_func('rb_thread_fd_select')
|
|
702
|
+
have_func('rb_io_descriptor', 'ruby/io.h')
|
|
702
703
|
have_func('rb_wait_for_single_fd', 'ruby/io.h')
|
|
703
704
|
have_header('ruby/fiber/scheduler.h')
|
|
704
705
|
have_func('rb_fiber_scheduler_current', 'ruby/fiber/scheduler.h')
|
data/lib/curl/easy.rb
CHANGED
|
@@ -53,6 +53,11 @@ module Curl
|
|
|
53
53
|
|
|
54
54
|
def flush_deferred_multi_closes(all_threads: false)
|
|
55
55
|
state = deferred_multi_close_state
|
|
56
|
+
# Fast path: nothing queued, so skip the mutex and list partitioning.
|
|
57
|
+
# A close queued concurrently by another thread is picked up next time.
|
|
58
|
+
queued = state[:deferred_multi_closes]
|
|
59
|
+
return if queued.nil? || queued.empty?
|
|
60
|
+
|
|
56
61
|
pending = deferred_multi_close_mutex.synchronize do
|
|
57
62
|
deferred_closes = (state[:deferred_multi_closes] ||= [])
|
|
58
63
|
|
|
@@ -184,10 +189,20 @@ module Curl
|
|
|
184
189
|
set_protocol_allowlist('CURLOPT_REDIR_PROTOCOLS_STR', 'CURLOPT_REDIR_PROTOCOLS', protocols)
|
|
185
190
|
end
|
|
186
191
|
|
|
187
|
-
def safe_http!
|
|
192
|
+
def safe_http!(options = {})
|
|
193
|
+
options = Curl.__send__(:safe_http_options, options)
|
|
194
|
+
config = Curl.__send__(:ractor_local_state)[:safety_config] || Curl::SafetyConfig.new
|
|
195
|
+
limit = Curl::SafetyConfig.new
|
|
196
|
+
limit.max_body_bytes = options.fetch(:max_body_bytes, config.max_body_bytes)
|
|
197
|
+
limit.timeout = options.fetch(:timeout, config.timeout)
|
|
198
|
+
limit.max_redirects = options.fetch(:max_redirects, config.max_redirects)
|
|
188
199
|
__curb_set_safety_override!(
|
|
189
200
|
:protocols => [:http, :https],
|
|
190
|
-
:redirect_protocols => [:http, :https]
|
|
201
|
+
:redirect_protocols => [:http, :https],
|
|
202
|
+
:network_policy => :public,
|
|
203
|
+
:max_body_bytes => limit.max_body_bytes,
|
|
204
|
+
:timeout => limit.timeout,
|
|
205
|
+
:max_redirects => limit.max_redirects
|
|
191
206
|
)
|
|
192
207
|
end
|
|
193
208
|
|
|
@@ -197,7 +212,10 @@ module Curl
|
|
|
197
212
|
@__curb_safety_override = (defined?(@__curb_safety_override) && @__curb_safety_override) ? @__curb_safety_override.dup : {}
|
|
198
213
|
@__curb_safety_override[:protocols] = Array(options[:protocols]).map { |protocol| protocol.to_s.downcase.to_sym } if options.key?(:protocols)
|
|
199
214
|
@__curb_safety_override[:redirect_protocols] = Array(options[:redirect_protocols]).map { |protocol| protocol.to_s.downcase.to_sym } if options.key?(:redirect_protocols)
|
|
200
|
-
@__curb_safety_override[:max_body_bytes] = options[:max_body_bytes] if options.key?(:max_body_bytes)
|
|
215
|
+
@__curb_safety_override[:max_body_bytes] = options[:max_body_bytes] if options.key?(:max_body_bytes)
|
|
216
|
+
@__curb_safety_override[:network_policy] = options[:network_policy] if options.key?(:network_policy)
|
|
217
|
+
@__curb_safety_override[:timeout] = options[:timeout] if options.key?(:timeout)
|
|
218
|
+
@__curb_safety_override[:max_redirects] = options[:max_redirects] if options.key?(:max_redirects)
|
|
201
219
|
@__curb_safety_override_generation = __curb_safety_override_generation + 1
|
|
202
220
|
Curl.__send__(:apply_safety!, self) if Curl.respond_to?(:apply_safety!, true)
|
|
203
221
|
self
|
|
@@ -263,7 +281,9 @@ module Curl
|
|
|
263
281
|
# the configured HTTP Verb.
|
|
264
282
|
#
|
|
265
283
|
def perform
|
|
266
|
-
|
|
284
|
+
# Validate eagerly so an unsafe request raises in the caller (and not in
|
|
285
|
+
# a shared scheduler driver fiber). Multi#add re-applies it as well.
|
|
286
|
+
Curl.__send__(:apply_safety!, self) if Curl.__send__(:safety_active_for?, self)
|
|
267
287
|
self.class.flush_deferred_multi_closes
|
|
268
288
|
|
|
269
289
|
if Curl.scheduler_active? && self.multi.nil?
|
data/lib/curl/multi.rb
CHANGED
|
@@ -353,6 +353,15 @@ module Curl
|
|
|
353
353
|
]
|
|
354
354
|
end
|
|
355
355
|
|
|
356
|
+
# True when no per-easy safety state (override or native policy) is set.
|
|
357
|
+
# Callers must separately check that no global Curl.safe! config exists.
|
|
358
|
+
def __curb_safety_unconstrained?(easy)
|
|
359
|
+
easy.instance_variable_get(:@__curb_safety_override).nil? &&
|
|
360
|
+
easy.network_policy == :none &&
|
|
361
|
+
easy.allowed_hosts.nil? &&
|
|
362
|
+
easy.allowed_cidrs.nil?
|
|
363
|
+
end
|
|
364
|
+
|
|
356
365
|
def __record_native_safety_signature(easy)
|
|
357
366
|
__curb_native_safety_signatures[easy.object_id] = __curb_safety_signature_for(easy)
|
|
358
367
|
end
|
|
@@ -419,24 +428,33 @@ module Curl
|
|
|
419
428
|
|
|
420
429
|
private :__idle_easy_references, :__curb_native_safety_signatures,
|
|
421
430
|
:__curb_safety_signature_for, :__record_native_safety_signature,
|
|
431
|
+
:__curb_safety_unconstrained?,
|
|
422
432
|
:__register_idle_easy_reference,
|
|
423
433
|
:__unregister_idle_easy_reference, :__clear_idle_easy_references,
|
|
424
434
|
:__new_idle_easy_references
|
|
425
435
|
|
|
426
436
|
alias_method :_curb_native_perform, :perform
|
|
427
437
|
|
|
428
|
-
def perform(
|
|
438
|
+
def perform(&block)
|
|
439
|
+
safety_config_active = !Curl.__send__(:ractor_local_state)[:safety_config].nil?
|
|
440
|
+
signatures = __curb_native_safety_signatures
|
|
441
|
+
|
|
429
442
|
requests.each_value do |easy|
|
|
430
|
-
|
|
443
|
+
# An easy added with no safety state recorded no signature. If it is
|
|
444
|
+
# still unconstrained its signature would be unchanged, so there is
|
|
445
|
+
# nothing to apply and no need to re-run native setup.
|
|
446
|
+
next if !safety_config_active && !signatures.key?(easy.object_id) &&
|
|
447
|
+
__curb_safety_unconstrained?(easy)
|
|
448
|
+
|
|
449
|
+
Curl.__send__(:apply_safety!, easy)
|
|
431
450
|
signature = __curb_safety_signature_for(easy)
|
|
432
|
-
if
|
|
433
|
-
easy.respond_to?(:__curb_native_setup!, true)
|
|
451
|
+
if signatures[easy.object_id] != signature
|
|
434
452
|
easy.__send__(:__curb_native_setup!)
|
|
435
|
-
|
|
453
|
+
signatures[easy.object_id] = signature
|
|
436
454
|
end
|
|
437
455
|
end
|
|
438
456
|
|
|
439
|
-
_curb_native_perform(
|
|
457
|
+
_curb_native_perform(&block)
|
|
440
458
|
end
|
|
441
459
|
|
|
442
460
|
def add(easy)
|
|
@@ -444,19 +462,23 @@ module Curl
|
|
|
444
462
|
# Once a deferred callback exception is pending, Multi#perform is
|
|
445
463
|
# draining existing transfers only and must not start replacement work.
|
|
446
464
|
return self if instance_variable_defined?(:@__curb_deferred_exception)
|
|
447
|
-
Curl.__send__(:
|
|
465
|
+
unconstrained = Curl.__send__(:ractor_local_state)[:safety_config].nil? &&
|
|
466
|
+
__curb_safety_unconstrained?(easy)
|
|
467
|
+
Curl.__send__(:apply_safety!, easy) unless unconstrained
|
|
448
468
|
_add(easy)
|
|
449
469
|
__unregister_idle_easy_reference(easy)
|
|
450
470
|
requests[easy.object_id] = easy
|
|
451
|
-
|
|
471
|
+
# Unconstrained easies record no signature; #perform treats a missing
|
|
472
|
+
# signature as "added with no safety state" (see #perform).
|
|
473
|
+
__record_native_safety_signature(easy) unless unconstrained
|
|
452
474
|
self
|
|
453
475
|
end
|
|
454
476
|
|
|
455
477
|
def remove(easy)
|
|
456
478
|
return self if !requests[easy.object_id]
|
|
479
|
+
_remove(easy)
|
|
457
480
|
requests.delete(easy.object_id)
|
|
458
481
|
__curb_native_safety_signatures.delete(easy.object_id)
|
|
459
|
-
_remove(easy)
|
|
460
482
|
self
|
|
461
483
|
end
|
|
462
484
|
|
data/lib/curl.rb
CHANGED
|
@@ -12,9 +12,14 @@ module Curl
|
|
|
12
12
|
# Ractors, even when the value itself would otherwise be safe. Keep curb's
|
|
13
13
|
# mutable Ruby-side configuration in storage owned by the current Ractor.
|
|
14
14
|
# Older Rubies retain the historical process-wide state.
|
|
15
|
+
# Ractor capabilities can't change at runtime, so probe them once instead of
|
|
16
|
+
# on every call: this lookup sits on the per-request path.
|
|
17
|
+
RACTOR_LOCAL_STORAGE = !!(defined?(Ractor) && Ractor.respond_to?(:current) &&
|
|
18
|
+
Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=))
|
|
19
|
+
private_constant :RACTOR_LOCAL_STORAGE
|
|
20
|
+
|
|
15
21
|
def self.ractor_local_state
|
|
16
|
-
if
|
|
17
|
-
Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=)
|
|
22
|
+
if RACTOR_LOCAL_STORAGE
|
|
18
23
|
Ractor.current[:__curb_ractor_local_state] ||= {}
|
|
19
24
|
else
|
|
20
25
|
@legacy_local_state ||= {}
|
|
@@ -23,15 +28,20 @@ module Curl
|
|
|
23
28
|
|
|
24
29
|
class SafetyConfig
|
|
25
30
|
DEFAULT_PROTOCOLS = [:http, :https].freeze
|
|
31
|
+
DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
|
32
|
+
DEFAULT_TIMEOUT = 60
|
|
33
|
+
DEFAULT_MAX_REDIRECTS = 20
|
|
26
34
|
|
|
27
|
-
attr_reader :max_body_bytes, :network_policy
|
|
35
|
+
attr_reader :max_body_bytes, :network_policy, :timeout, :max_redirects
|
|
28
36
|
attr_accessor :allow_proxies, :allow_resolve, :allow_connect_to, :allow_doh, :allow_unix_socket
|
|
29
37
|
|
|
30
38
|
def initialize
|
|
31
39
|
@protocols = DEFAULT_PROTOCOLS
|
|
32
40
|
@redirect_protocols = nil
|
|
33
|
-
@max_body_bytes =
|
|
34
|
-
@
|
|
41
|
+
@max_body_bytes = DEFAULT_MAX_BODY_BYTES
|
|
42
|
+
@timeout = DEFAULT_TIMEOUT
|
|
43
|
+
@max_redirects = DEFAULT_MAX_REDIRECTS
|
|
44
|
+
@network_policy = :public
|
|
35
45
|
@allowed_hosts = nil
|
|
36
46
|
@allowed_proxy_hosts = nil
|
|
37
47
|
@allowed_cidrs = nil
|
|
@@ -82,6 +92,32 @@ module Curl
|
|
|
82
92
|
@max_body_bytes = bytes.zero? ? nil : bytes
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
# Seconds; applied to handles with no timeout of their own. nil disables.
|
|
96
|
+
def timeout=(seconds)
|
|
97
|
+
if seconds.nil?
|
|
98
|
+
@timeout = nil
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
seconds = Float(seconds)
|
|
103
|
+
raise ArgumentError, "timeout must be greater than zero" unless seconds > 0
|
|
104
|
+
|
|
105
|
+
@timeout = seconds
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Applied to handles whose max_redirects is unset or unlimited. nil disables.
|
|
109
|
+
def max_redirects=(count)
|
|
110
|
+
if count.nil?
|
|
111
|
+
@max_redirects = nil
|
|
112
|
+
return
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
count = Integer(count)
|
|
116
|
+
raise ArgumentError, "max_redirects must be greater than or equal to zero" if count < 0
|
|
117
|
+
|
|
118
|
+
@max_redirects = count
|
|
119
|
+
end
|
|
120
|
+
|
|
85
121
|
def network_policy=(policy)
|
|
86
122
|
@network_policy = normalize_network_policy(policy)
|
|
87
123
|
end
|
|
@@ -217,16 +253,35 @@ module Curl
|
|
|
217
253
|
apply_allowed_hosts!(easy, config.allowed_hosts) if config&.allowed_hosts
|
|
218
254
|
apply_allowed_cidrs!(easy, config) if config&.allowed_cidrs
|
|
219
255
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
256
|
+
network_policy = config&.network_policy
|
|
257
|
+
network_policy = :public if override && override[:network_policy] == :public
|
|
258
|
+
if network_policy
|
|
259
|
+
easy.network_policy = network_policy
|
|
260
|
+
apply_public_network_policy_controls!(easy, config || SafetyConfig.new) if network_policy == :public
|
|
223
261
|
end
|
|
224
262
|
|
|
225
263
|
apply_max_body_bytes!(easy, config.max_body_bytes) if config&.max_body_bytes
|
|
226
264
|
apply_max_body_bytes!(easy, override[:max_body_bytes]) if override && override.key?(:max_body_bytes)
|
|
265
|
+
apply_default_timeout!(easy, override && override.key?(:timeout) ? override[:timeout] : config&.timeout)
|
|
266
|
+
apply_default_max_redirects!(easy, override && override.key?(:max_redirects) ? override[:max_redirects] : config&.max_redirects)
|
|
227
267
|
easy
|
|
228
268
|
end
|
|
229
269
|
|
|
270
|
+
# Only fills in a limit the handle has not chosen itself.
|
|
271
|
+
def self.apply_default_timeout!(easy, seconds)
|
|
272
|
+
return unless seconds
|
|
273
|
+
return unless easy.timeout_ms.to_i.zero?
|
|
274
|
+
|
|
275
|
+
easy.timeout = seconds
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def self.apply_default_max_redirects!(easy, count)
|
|
279
|
+
return unless count
|
|
280
|
+
|
|
281
|
+
current = easy.max_redirects
|
|
282
|
+
easy.max_redirects = count if current.nil? || current < 0
|
|
283
|
+
end
|
|
284
|
+
|
|
230
285
|
def self.clear_safe!
|
|
231
286
|
ractor_local_state[:safety_config] = nil
|
|
232
287
|
bump_safety_generation!
|
|
@@ -247,9 +302,7 @@ module Curl
|
|
|
247
302
|
end
|
|
248
303
|
|
|
249
304
|
def self.safety_override_for(easy)
|
|
250
|
-
|
|
251
|
-
easy.__send__(:__curb_safety_override)
|
|
252
|
-
end
|
|
305
|
+
easy.instance_variable_get(:@__curb_safety_override)
|
|
253
306
|
end
|
|
254
307
|
|
|
255
308
|
def self.safety_generation
|
|
@@ -414,6 +467,7 @@ module Curl
|
|
|
414
467
|
:safety_generation, :bump_safety_generation!,
|
|
415
468
|
:safety_protocol_intersection,
|
|
416
469
|
:apply_max_body_bytes!, :apply_public_network_policy_controls!,
|
|
470
|
+
:apply_default_timeout!, :apply_default_max_redirects!,
|
|
417
471
|
:apply_allowed_hosts!, :apply_allowed_cidrs!,
|
|
418
472
|
:apply_allowed_proxy!, :normalize_proxy_host,
|
|
419
473
|
:reject_resolve_override!, :reject_connect_to_override!,
|
|
@@ -421,6 +475,12 @@ module Curl
|
|
|
421
475
|
:reject_unix_socket_override!,
|
|
422
476
|
:disable_proxy!
|
|
423
477
|
|
|
478
|
+
# Multi#perform under a fiber scheduler uses the socket-action loop, which
|
|
479
|
+
# waits through the scheduler, exactly when this native method exists
|
|
480
|
+
# (same build condition). Otherwise it falls back to a thread-blocking wait.
|
|
481
|
+
SCHEDULER_COOPERATIVE_PERFORM = Curl::Multi.private_method_defined?(:_socket_perform)
|
|
482
|
+
private_constant :SCHEDULER_COOPERATIVE_PERFORM
|
|
483
|
+
|
|
424
484
|
def self.scheduler_active?
|
|
425
485
|
Fiber.respond_to?(:scheduler) && !Fiber.scheduler.nil?
|
|
426
486
|
end
|
|
@@ -600,7 +660,11 @@ module Curl
|
|
|
600
660
|
state[:multi].perform do
|
|
601
661
|
drain_scheduler_pending(state)
|
|
602
662
|
release_scheduler_waiters(state)
|
|
603
|
-
|
|
663
|
+
# The socket-action loop already waits through the scheduler on
|
|
664
|
+
# every pass, so sibling fibers run without an extra yield (which
|
|
665
|
+
# cost ~4 yields per request). The fallback loop blocks the thread
|
|
666
|
+
# while waiting, so it still needs one.
|
|
667
|
+
scheduler_yield unless SCHEDULER_COOPERATIVE_PERFORM
|
|
604
668
|
end
|
|
605
669
|
ensure
|
|
606
670
|
# Release any siblings that completed just before a deferred
|
|
@@ -653,8 +717,7 @@ module Curl
|
|
|
653
717
|
|
|
654
718
|
http(verb, url, post_body, put_data) do |handle|
|
|
655
719
|
yield handle if block
|
|
656
|
-
handle.safe_http!
|
|
657
|
-
handle.max_body_bytes = options[:max_body_bytes] if options.key?(:max_body_bytes)
|
|
720
|
+
handle.safe_http!(options)
|
|
658
721
|
end
|
|
659
722
|
end
|
|
660
723
|
|
|
@@ -755,7 +818,7 @@ module Curl
|
|
|
755
818
|
end
|
|
756
819
|
|
|
757
820
|
def self.safe_http_option_keys
|
|
758
|
-
[:max_body_bytes]
|
|
821
|
+
[:max_body_bytes, :timeout, :max_redirects]
|
|
759
822
|
end
|
|
760
823
|
|
|
761
824
|
def self.reset
|
|
@@ -4,7 +4,7 @@ class BugFollowRedirect288 < Test::Unit::TestCase
|
|
|
4
4
|
include BugTestServerSetupTeardown
|
|
5
5
|
|
|
6
6
|
def setup
|
|
7
|
-
@port =
|
|
7
|
+
@port = :auto
|
|
8
8
|
super
|
|
9
9
|
@server.mount_proc("/redirect_to_test") do|req,res|
|
|
10
10
|
res.set_redirect(WEBrick::HTTPStatus::TemporaryRedirect, "/test")
|
data/tests/bug_poison.rb
CHANGED
data/tests/helper.rb
CHANGED
|
@@ -206,16 +206,17 @@ class TestServlet < WEBrick::HTTPServlet::AbstractServlet
|
|
|
206
206
|
end
|
|
207
207
|
|
|
208
208
|
module BugTestServerSetupTeardown
|
|
209
|
-
def unused_local_port
|
|
210
|
-
socket = TCPServer.new('127.0.0.1', 0)
|
|
211
|
-
socket.addr[1]
|
|
212
|
-
ensure
|
|
213
|
-
socket.close if socket
|
|
214
|
-
end
|
|
215
|
-
|
|
216
209
|
def setup
|
|
217
210
|
@port ||= 9992
|
|
218
|
-
|
|
211
|
+
# Bind 127.0.0.1 explicitly: with no bind address, WEBrick silently keeps
|
|
212
|
+
# just the IPv6 listener if the IPv4 bind fails, leaving 127.0.0.1:@port
|
|
213
|
+
# refusing connections. :auto lets the kernel pick a free port at bind
|
|
214
|
+
# time instead of reserving one and re-binding it later, which could race
|
|
215
|
+
# with client connections reusing that ephemeral port (seen on macOS).
|
|
216
|
+
@server = WEBrick::HTTPServer.new(:Port => (@port == :auto ? 0 : @port),
|
|
217
|
+
:BindAddress => '127.0.0.1',
|
|
218
|
+
:Logger => WEBRICK_TEST_LOG, :AccessLog => [])
|
|
219
|
+
@port = @server.config[:Port]
|
|
219
220
|
@server.mount_proc("/test") do|req,res|
|
|
220
221
|
if @response_proc
|
|
221
222
|
@response_proc.call(res)
|