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.
@@ -12,6 +12,110 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
12
12
  super
13
13
  end
14
14
 
15
+ def test_safe_bang_defaults_to_public_network_policy
16
+ assert_equal :public, Curl::SafetyConfig.new.network_policy
17
+ require_public_network_policy!
18
+ Curl.safe!
19
+
20
+ assert_raise(Curl::Err::UnsafeDestinationError) { Curl.get(TestServlet.url) }
21
+ easy = Curl::Easy.new(TestServlet.url)
22
+ easy.network_policy = :none
23
+ assert_raise(Curl::Err::UnsafeDestinationError) { easy.perform }
24
+ assert_equal :public, easy.network_policy
25
+ end
26
+
27
+ def test_safe_bang_default_blocks_private_and_metadata_destinations
28
+ require_public_network_policy!
29
+ Curl.safe!
30
+ ['10.0.0.1', '172.16.0.1', '192.168.0.1', '169.254.169.254', '[::1]'].each do |host|
31
+ assert_raise(Curl::Err::UnsafeDestinationError, host) do
32
+ Curl.get("http://#{host}/") do |easy|
33
+ easy.network_policy = :none
34
+ easy.connect_timeout_ms = 100
35
+ easy.timeout_ms = 200
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def test_safe_bang_default_applies_to_previously_added_multi_handles
42
+ require_public_network_policy!
43
+ easy = Curl::Easy.new(TestServlet.url)
44
+ multi = Curl::Multi.new
45
+ multi.add(easy)
46
+ Curl.safe!
47
+ multi.perform
48
+
49
+ assert_equal :public, easy.network_policy
50
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
51
+ ensure
52
+ multi.close if multi
53
+ end
54
+
55
+ def test_safe_helpers_block_loopback_without_global_policy
56
+ require_public_network_policy!
57
+ [:get, :post, :put, :delete, :patch, :head, :options].each do |verb|
58
+ assert_raise(Curl::Err::UnsafeDestinationError, verb.to_s) do
59
+ Curl.public_send("safe_#{verb}", TestServlet.url) do |easy|
60
+ easy.network_policy = :none
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ def test_safe_http_reapplies_public_policy_before_perform
67
+ require_public_network_policy!
68
+ easy = Curl::Easy.new(TestServlet.url)
69
+ assert_same easy, easy.safe_http!
70
+ assert_equal :public, easy.network_policy
71
+ easy.network_policy = :none
72
+
73
+ assert_raise(Curl::Err::UnsafeDestinationError) { easy.perform }
74
+ end
75
+
76
+ def test_safe_http_cannot_be_weakened_by_global_policy
77
+ require_public_network_policy!
78
+ Curl.safe! { |config| config.network_policy = :none }
79
+
80
+ assert_raise(Curl::Err::UnsafeDestinationError) { Curl.safe_get(TestServlet.url) }
81
+ end
82
+
83
+ def test_safe_helpers_block_private_and_metadata_destinations
84
+ require_public_network_policy!
85
+ ['10.0.0.1', '172.16.0.1', '192.168.0.1', '169.254.169.254', '[::1]'].each do |host|
86
+ assert_raise(Curl::Err::UnsafeDestinationError, host) do
87
+ Curl.safe_get("http://#{host}/") do |easy|
88
+ easy.connect_timeout_ms = 100
89
+ easy.timeout_ms = 200
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ def test_safe_http_rejects_resolve_override_without_global_policy
96
+ require_public_network_policy!
97
+ assert_raise(Curl::Err::UnsafeDestinationError) do
98
+ Curl.safe_get('http://example.invalid/') do |easy|
99
+ easy.resolve = ['example.invalid:80:127.0.0.1']
100
+ end
101
+ end
102
+ end
103
+
104
+ def test_safe_http_applies_to_multi
105
+ require_public_network_policy!
106
+ easy = Curl::Easy.new(TestServlet.url)
107
+ easy.safe_http!
108
+ easy.network_policy = :none
109
+ multi = Curl::Multi.new
110
+ multi.add(easy)
111
+ multi.perform
112
+
113
+ assert_equal :public, easy.network_policy
114
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
115
+ ensure
116
+ multi.close if multi
117
+ end
118
+
15
119
  def test_network_policy_defaults_to_none
16
120
  easy = Curl::Easy.new
17
121
 
@@ -95,6 +199,7 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
95
199
 
96
200
  with_host_redirect_server(allowed_host, allowed_host) do |port, hits|
97
201
  Curl.safe! do |config|
202
+ config.network_policy = :none # Isolate host filtering from peer filtering.
98
203
  config.allowed_hosts = [allowed_host]
99
204
  end
100
205
 
@@ -119,6 +224,7 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
119
224
 
120
225
  with_host_redirect_server(allowed_host, blocked_host) do |port, hits|
121
226
  Curl.safe! do |config|
227
+ config.network_policy = :none # Isolate host filtering from peer filtering.
122
228
  config.allowed_hosts = [allowed_host]
123
229
  end
124
230
 
@@ -142,6 +248,7 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
142
248
 
143
249
  def test_safe_bang_allowed_cidrs_requires_public_network_policy
144
250
  Curl.safe! do |config|
251
+ config.network_policy = :none # Isolate the host/CIDR policy from peer filtering.
145
252
  config.allowed_cidrs = ['1.1.1.0/24']
146
253
  end
147
254
 
@@ -911,43 +1018,36 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
911
1018
  Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
912
1019
  end
913
1020
 
914
- def test_public_network_policy_blocks_private_peer_after_live_dns_answer_changes
915
- omit('live DNS rebinding test requires CURLOPT_DOH_URL') unless Curl.const_defined?(:CURLOPT_DOH_URL)
916
- omit('live DNS rebinding test requires CURLOPT_DOH_SSL_VERIFYPEER') unless Curl.const_defined?(:CURLOPT_DOH_SSL_VERIFYPEER)
917
- omit('live DNS rebinding test requires CURLOPT_DOH_SSL_VERIFYHOST') unless Curl.const_defined?(:CURLOPT_DOH_SSL_VERIFYHOST)
1021
+ # The peer address is vetted at connect time for every resolution, so a
1022
+ # name that resolves to an allowed address once and a private one later is
1023
+ # still blocked. (This used a loopback DoH server as the changing resolver;
1024
+ # public policy now refuses non-public DoH endpoints, so CURLOPT_RESOLVE
1025
+ # supplies the two answers instead.)
1026
+ def test_public_network_policy_blocks_private_peer_after_dns_answer_changes
918
1027
  require_public_network_policy!
919
1028
 
920
- host = 'curb-doh-rebind.invalid'
921
- with_rebinding_doh_server(host, '1.1.1.1') do |doh_url, doh_state|
922
- Curl.safe! do |config|
923
- config.network_policy = :public
924
- config.allow_doh = true
925
- config.allowed_cidrs = ['8.8.8.0/24']
926
- end
927
-
928
- first = Curl::Easy.new("http://#{host}/dns-rebind-target")
929
- configure_live_doh_easy(first, doh_url)
930
-
931
- first_error = assert_raise(Curl::Err::UnsafeDestinationError) do
932
- first.perform
933
- end
934
-
935
- assert_include doh_a_answers(doh_state), '1.1.1.1'
936
- assert_match(/outside allowed CIDR ranges/, first_error.message)
937
- assert_match(/1\.1\.1\.1/, first_error.message)
938
-
939
- set_doh_answer(doh_state, '127.0.0.1')
940
- second = Curl::Easy.new("http://#{host}/dns-rebind-target")
941
- 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
942
1035
 
943
- error = assert_raise(Curl::Err::UnsafeDestinationError) do
944
- second.perform
945
- end
1036
+ first = Curl::Easy.new("http://#{host}/dns-rebind-target")
1037
+ first.resolve = ["#{host}:80:1.1.1.1"]
1038
+ first_error = assert_raise(Curl::Err::UnsafeDestinationError) do
1039
+ first.perform
1040
+ end
1041
+ assert_match(/outside allowed CIDR ranges/, first_error.message)
1042
+ assert_match(/1\.1\.1\.1/, first_error.message)
946
1043
 
947
- assert_include doh_a_answers(doh_state), '127.0.0.1'
948
- assert_match(/public network policy/, error.message)
949
- assert_match(/127\.0\.0\.1/, error.message)
1044
+ second = Curl::Easy.new("http://#{host}/dns-rebind-target")
1045
+ second.resolve = ["#{host}:80:127.0.0.1"]
1046
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
1047
+ second.perform
950
1048
  end
1049
+ assert_match(/public network policy/, error.message)
1050
+ assert_match(/127\.0\.0\.1/, error.message)
951
1051
  ensure
952
1052
  Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
953
1053
  end
@@ -1080,7 +1180,7 @@ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
1080
1180
  multi = Curl::Multi.new
1081
1181
 
1082
1182
  multi.add(easy)
1083
- Curl.safe!
1183
+ Curl.safe! { |config| config.network_policy = :none }
1084
1184
  multi.perform
1085
1185
 
1086
1186
  assert_equal [['1']], request_headers
@@ -253,6 +253,143 @@ class TestCurbCurlPostfieldMultipartCoverage < Test::Unit::TestCase
253
253
  server_setup
254
254
  end
255
255
 
256
+ [:http_post, :http_patch, :http_put].each do |method|
257
+ [:close, :reset, :initialize, :perform, :http_post, :post_body=].each do |operation|
258
+ [:proc, :coercion].each do |source|
259
+ define_method("test_#{method}_guards_#{operation}_from_#{source}") do
260
+ curl = Curl::Easy.new(TestServlet.url)
261
+ curl.multipart_form_post = true
262
+ action = lambda do |*_|
263
+ if operation == :post_body=
264
+ curl.post_body = 'replacement'
265
+ elsif operation == :http_post
266
+ curl.http_post(Curl::PostField.content('nested', 'payload'))
267
+ else
268
+ curl.__send__(operation)
269
+ end
270
+ 'payload'
271
+ end
272
+ field = if source == :proc
273
+ Curl::PostField.content('trigger', &action)
274
+ else
275
+ value = Object.new
276
+ value.define_singleton_method(:to_str, &action)
277
+ Curl::PostField.content('trigger', value)
278
+ end
279
+
280
+ error = assert_raise(RuntimeError) do
281
+ curl.__send__(method, Curl::PostField.content('first', 'allocated'), field)
282
+ end
283
+ assert_match(/active curl handle/, error.message)
284
+
285
+ # Both partial forms and guards must unwind before reuse.
286
+ curl.multipart_form_post = false
287
+ curl.perform
288
+ assert_equal 'GET', curl.body_str
289
+ assert_nothing_raised { curl.reset }
290
+ assert_nothing_raised { curl.close }
291
+ end
292
+ end
293
+ end
294
+ end
295
+
296
+ [:content, :file].each do |kind|
297
+ [false, true].each do |dynamic|
298
+ define_method("test_multipart_snapshots_#{kind}_#{dynamic ? 'proc' : 'direct'}") do
299
+ name = +'original_name'
300
+ payload = +"original\0payload"
301
+ field = multipart_field(kind, dynamic, nil, payload)
302
+ field.name = name
303
+ later = Curl::PostField.content('later') do
304
+ Thread.new do
305
+ name.replace('changed_name' * 1000)
306
+ payload.replace('changed_payload' * 1000)
307
+ field.content = nil
308
+ field.name = nil
309
+ GC.start
310
+ GC.verify_compaction_references(double_heap: true, toward: :empty) if GC.respond_to?(:verify_compaction_references)
311
+ end.join
312
+ 'later_value'
313
+ end
314
+ curl = Curl::Easy.new(TestServlet.url)
315
+ curl.multipart_form_post = true
316
+ curl.http_post(field, later)
317
+
318
+ assert_include curl.body_str, 'name="original_name"'
319
+ assert_include curl.body_str, "original\0payload"
320
+ assert_include curl.body_str, 'filename="remote.txt"' if kind == :file
321
+ assert_not_include curl.body_str, 'changed_payload'
322
+ end
323
+ end
324
+ end
325
+
326
+ def test_multipart_snapshots_before_later_coercions_in_same_field
327
+ name = +'original_name'
328
+ payload = +'original_payload'
329
+ content_type = Object.new
330
+ content_type.define_singleton_method(:to_str) do
331
+ name.replace('changed_name' * 1000)
332
+ payload.replace('changed_payload' * 1000)
333
+ 'text/plain'
334
+ end
335
+ field = Curl::PostField.content(name, payload, content_type)
336
+ curl = Curl::Easy.new(TestServlet.url)
337
+ curl.multipart_form_post = true
338
+ curl.http_post(field)
339
+
340
+ assert_include curl.body_str, 'name="original_name"'
341
+ assert_include curl.body_str, 'original_payload'
342
+ end
343
+
344
+ # Exercise each pointer/length pair with and without a content type.
345
+ [:content, :file].each do |kind|
346
+ [false, true].each do |dynamic|
347
+ [nil, 'text/plain'].each do |content_type|
348
+ variant = "#{kind}_#{dynamic ? 'proc' : 'direct'}_#{content_type ? 'typed' : 'untyped'}"
349
+
350
+ define_method("test_multipart_coerces_#{variant}") do
351
+ payload = "before\0after"
352
+ string_like = Object.new
353
+ string_like.define_singleton_method(:to_str) { payload }
354
+ field = multipart_field(kind, dynamic, content_type, string_like)
355
+ curl = Curl::Easy.new(TestServlet.url)
356
+ curl.multipart_form_post = true
357
+
358
+ curl.http_post(field)
359
+
360
+ assert_include curl.body_str, payload
361
+ assert_equal payload, field.content unless dynamic
362
+ end
363
+
364
+ define_method("test_multipart_rejects_non_string_#{variant}") do
365
+ field = multipart_field(kind, dynamic, content_type, 5)
366
+ curl = Curl::Easy.new(TestServlet.url)
367
+ curl.multipart_form_post = true
368
+
369
+ assert_raise(TypeError) { curl.http_post(field) }
370
+ end
371
+ end
372
+ end
373
+ end
374
+
375
+ def multipart_field(kind, dynamic, content_type, value)
376
+ field = if dynamic
377
+ if kind == :file
378
+ Curl::PostField.file('upload', 'remote.txt') { value }
379
+ else
380
+ Curl::PostField.content('value') { value }
381
+ end
382
+ elsif kind == :file
383
+ upload = Curl::PostField.file('upload', 'remote.txt')
384
+ upload.content = value
385
+ upload
386
+ else
387
+ Curl::PostField.content('value', value)
388
+ end
389
+ field.content_type = content_type
390
+ field
391
+ end
392
+
256
393
  def test_multipart_content_variants_include_dynamic_and_typed_fields
257
394
  curl = Curl::Easy.new(TestServlet.url)
258
395
  curl.multipart_form_post = true
@@ -104,11 +104,10 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
104
104
  end
105
105
  end
106
106
 
107
- def test_safe_get_allows_http_protocol
108
- easy = Curl.safe_get(TestServlet.url)
109
-
110
- assert_equal 200, easy.response_code
111
- assert_match(/GET/, easy.body_str)
107
+ def test_safe_get_blocks_local_http_destination
108
+ assert_raise(Curl::Err::UnsafeDestinationError) do
109
+ Curl.safe_get(TestServlet.url)
110
+ end
112
111
  end
113
112
 
114
113
  def test_safe_bang_denies_file_protocol_for_easy_perform
@@ -159,8 +158,8 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
159
158
  end
160
159
  end
161
160
 
162
- def test_safe_bang_allows_http_protocol
163
- Curl.safe!
161
+ def test_safe_bang_explicit_none_allows_trusted_http_destination
162
+ Curl.safe! { |config| config.network_policy = :none }
164
163
  easy = Curl.get(TestServlet.url)
165
164
 
166
165
  assert_equal 200, easy.response_code
@@ -184,6 +183,7 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
184
183
  with_file_redirect_server do |url|
185
184
  easy = Curl::Easy.new(url)
186
185
  easy.follow_location = true
186
+ stub_public_network_policy_for_local_fixture(easy)
187
187
  easy.safe_http!
188
188
 
189
189
  assert_raises Curl::Err::UnsupportedProtocolError do
@@ -198,6 +198,7 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
198
198
 
199
199
  easy = Curl::Easy.new(url)
200
200
  easy.follow_location = true
201
+ stub_public_network_policy_for_local_fixture(easy)
201
202
 
202
203
  assert_raises Curl::Err::UnsupportedProtocolError do
203
204
  easy.perform
@@ -211,6 +212,7 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
211
212
  easy.follow_location = true
212
213
  easy.ssl_verify_peer = false
213
214
  easy.ssl_verify_host = false
215
+ stub_public_network_policy_for_local_fixture(easy)
214
216
  easy.safe_http!
215
217
 
216
218
  easy.perform
@@ -226,6 +228,7 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
226
228
  easy.follow_location = true
227
229
  easy.ssl_verify_peer = false
228
230
  easy.ssl_verify_host = false
231
+ stub_public_network_policy_for_local_fixture(easy)
229
232
  easy.safe_http!
230
233
 
231
234
  easy.perform
@@ -240,6 +243,7 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
240
243
  easy = Curl::Easy.new(url)
241
244
  easy.follow_location = true
242
245
  easy.max_redirects = 2
246
+ stub_public_network_policy_for_local_fixture(easy)
243
247
  easy.safe_http!
244
248
 
245
249
  assert_raises Curl::Err::TooManyRedirectsError do
@@ -252,6 +256,14 @@ class TestCurbCurlProtocols < Test::Unit::TestCase
252
256
 
253
257
  private
254
258
 
259
+ # Isolate protocol/redirect behavior using local fixtures. Destination blocking
260
+ # is exercised without this stub in tc_curl_network_policy.rb.
261
+ def stub_public_network_policy_for_local_fixture(easy)
262
+ def easy.network_policy=(policy)
263
+ raise "unexpected policy: #{policy}" unless policy == :public
264
+ end
265
+ end
266
+
255
267
  def with_file_redirect_server
256
268
  port_socket = TCPServer.new('127.0.0.1', 0)
257
269
  port = port_socket.addr[1]