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
|
@@ -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
|
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
|
@@ -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!
|
|
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
|
+
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
|
|
@@ -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
|