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.
- checksums.yaml +4 -4
- data/ChangeLog.md +16 -1
- data/README.md +17 -6
- data/ext/curb.h +4 -4
- data/ext/curb_easy.c +302 -34
- data/ext/curb_easy.h +5 -0
- data/ext/curb_multi.c +32 -15
- data/ext/extconf.rb +1 -0
- data/lib/curl/easy.rb +15 -2
- data/lib/curl/multi.rb +30 -8
- data/lib/curl.rb +69 -8
- 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 +188 -0
- data/tests/tc_curl_multi.rb +32 -0
- data/tests/tc_curl_network_policy.rb +25 -32
- data/tests/tc_curl_security_hardening.rb +386 -0
- data/tests/tc_fiber_scheduler.rb +46 -6
- data/tests/tc_ractor.rb +5 -1
- metadata +4 -2
data/tests/tc_curl_multi.rb
CHANGED
|
@@ -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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
data/tests/tc_fiber_scheduler.rb
CHANGED
|
@@ -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 =
|
|
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 =>
|
|
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
|
-
|
|
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
|
+
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-
|
|
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
|