curb 1.3.5 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1475 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ class TestCurbCurlNetworkPolicy < Test::Unit::TestCase
4
+ include TestServerMethods
5
+
6
+ def setup
7
+ server_setup
8
+ end
9
+
10
+ def teardown
11
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
12
+ super
13
+ end
14
+
15
+ def test_network_policy_defaults_to_none
16
+ easy = Curl::Easy.new
17
+
18
+ assert_equal :none, easy.network_policy
19
+ end
20
+
21
+ def test_network_policy_rejects_unknown_policy
22
+ easy = Curl::Easy.new
23
+
24
+ assert_raise(ArgumentError) do
25
+ easy.network_policy = :private
26
+ end
27
+ end
28
+
29
+ def test_safe_bang_allowed_hosts_rejects_unlisted_initial_host
30
+ Curl.safe! do |config|
31
+ config.allowed_hosts = ['allowed.example']
32
+ end
33
+
34
+ easy = Curl::Easy.new('http://blocked.example/')
35
+
36
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
37
+ Curl.__send__(:apply_safety!, easy)
38
+ end
39
+
40
+ assert_match(/host allowlist/, error.message)
41
+ assert_match(/blocked\.example/, error.message)
42
+ end
43
+
44
+ def test_safe_bang_allowed_hosts_accepts_matching_initial_host
45
+ Curl.safe! do |config|
46
+ config.allowed_hosts = ['ALLOWED.example.']
47
+ end
48
+
49
+ easy = Curl::Easy.new('http://allowed.example/path')
50
+
51
+ Curl.__send__(:apply_safety!, easy)
52
+ end
53
+
54
+ def test_safe_bang_allowed_hosts_strips_ports_without_scheme
55
+ Curl.safe! do |config|
56
+ config.allowed_hosts = ['api.example:443', '[2001:db8::1]:443']
57
+ end
58
+
59
+ easy = Curl::Easy.new('http://api.example/path')
60
+ Curl.__send__(:apply_safety!, easy)
61
+
62
+ assert_equal ['api.example', '2001:db8::1'], easy.allowed_hosts
63
+ end
64
+
65
+ def test_safety_allowlists_are_deduplicated_after_normalization
66
+ require_public_network_policy!
67
+
68
+ Curl.safe! do |config|
69
+ config.network_policy = :public
70
+ config.allowed_hosts = ['API.example.', 'api.example', 'https://api.example/path']
71
+ config.allowed_cidrs = ['1.1.1.0/24', '1.1.1.0/24']
72
+ end
73
+
74
+ easy = Curl::Easy.new('http://api.example/')
75
+ Curl.__send__(:apply_safety!, easy)
76
+
77
+ assert_equal ['api.example'], easy.allowed_hosts
78
+ assert_equal ['1.1.1.0/24'], easy.allowed_cidrs
79
+ end
80
+
81
+ def test_easy_allowlists_are_deduplicated_after_normalization
82
+ easy = Curl::Easy.new
83
+
84
+ easy.allowed_hosts = ['API.example.', 'api.example', 'https://api.example/path']
85
+ easy.allowed_cidrs = ['1.1.1.0/24', '1.1.1.0/24']
86
+
87
+ assert_equal ['api.example'], easy.allowed_hosts
88
+ assert_equal ['1.1.1.0/24'], easy.allowed_cidrs
89
+ end
90
+
91
+ def test_safe_bang_allowed_hosts_allows_same_host_redirect
92
+ omit('redirect-aware host allowlists require CURLOPT_PREREQFUNCTION') unless Curl.const_defined?(:CURLOPT_PREREQFUNCTION)
93
+
94
+ allowed_host = 'curb-allowed.test'
95
+
96
+ with_host_redirect_server(allowed_host, allowed_host) do |port, hits|
97
+ Curl.safe! do |config|
98
+ config.allowed_hosts = [allowed_host]
99
+ end
100
+
101
+ easy = Curl::Easy.new("http://#{allowed_host}:#{port}/redirect-to-target")
102
+ easy.resolve = [resolve_entry(allowed_host, port, '127.0.0.1')]
103
+ easy.follow_location = true
104
+
105
+ easy.perform
106
+
107
+ assert_equal 200, easy.response_code
108
+ assert_equal 'target', easy.body_str
109
+ assert_equal 1, hits[:redirect]
110
+ assert_equal 1, hits[:target]
111
+ end
112
+ end
113
+
114
+ def test_safe_bang_allowed_hosts_blocks_redirect_to_unlisted_host
115
+ omit('redirect-aware host allowlists require CURLOPT_PREREQFUNCTION') unless Curl.const_defined?(:CURLOPT_PREREQFUNCTION)
116
+
117
+ allowed_host = 'curb-allowed.test'
118
+ blocked_host = 'curb-blocked.test'
119
+
120
+ with_host_redirect_server(allowed_host, blocked_host) do |port, hits|
121
+ Curl.safe! do |config|
122
+ config.allowed_hosts = [allowed_host]
123
+ end
124
+
125
+ easy = Curl::Easy.new("http://#{allowed_host}:#{port}/redirect-to-target")
126
+ easy.resolve = [
127
+ resolve_entry(allowed_host, port, '127.0.0.1'),
128
+ resolve_entry(blocked_host, port, '127.0.0.1')
129
+ ]
130
+ easy.follow_location = true
131
+
132
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
133
+ easy.perform
134
+ end
135
+
136
+ assert_match(/host allowlist/, error.message)
137
+ assert_match(/#{Regexp.escape(blocked_host)}/, error.message)
138
+ assert_equal 1, hits[:redirect]
139
+ assert_equal 0, hits[:target], 'blocked redirected host should not receive a request'
140
+ end
141
+ end
142
+
143
+ def test_safe_bang_allowed_cidrs_requires_public_network_policy
144
+ Curl.safe! do |config|
145
+ config.allowed_cidrs = ['1.1.1.0/24']
146
+ end
147
+
148
+ easy = Curl::Easy.new('http://1.1.1.1/')
149
+
150
+ assert_raise(ArgumentError) do
151
+ Curl.__send__(:apply_safety!, easy)
152
+ end
153
+ end
154
+
155
+ def test_allowed_cidrs_rejects_invalid_ranges
156
+ easy = Curl::Easy.new
157
+
158
+ assert_raise(ArgumentError) do
159
+ easy.allowed_cidrs = ['not-a-cidr']
160
+ end
161
+
162
+ assert_raise(ArgumentError) do
163
+ Curl.safe! { |config| config.allowed_cidrs = ['1.1.1.1/33'] }
164
+ end
165
+ end
166
+
167
+ def test_clone_preserves_allowed_hosts_for_native_prereq_checks
168
+ omit('redirect-aware host allowlists require CURLOPT_PREREQFUNCTION') unless Curl.const_defined?(:CURLOPT_PREREQFUNCTION)
169
+
170
+ allowed_host = 'curb-clone-allowed.test'
171
+ blocked_host = 'curb-clone-blocked.test'
172
+ original = Curl::Easy.new("http://#{allowed_host}:#{TestServlet.port}#{TestServlet.path}")
173
+ original.allowed_hosts = [allowed_host]
174
+
175
+ clone = original.clone
176
+ clone.url = "http://#{blocked_host}:#{TestServlet.port}#{TestServlet.path}"
177
+ clone.resolve = [resolve_entry(blocked_host, TestServlet.port, '127.0.0.1')]
178
+ clone.connect_timeout_ms = 50
179
+
180
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
181
+ clone.perform
182
+ end
183
+
184
+ assert_match(/host allowlist/, error.message)
185
+ assert_match(/#{Regexp.escape(blocked_host)}/, error.message)
186
+ end
187
+
188
+ def test_clone_preserves_allowed_cidrs_for_native_public_policy_checks
189
+ require_public_network_policy!
190
+
191
+ original = Curl::Easy.new('http://1.1.1.1:81/')
192
+ original.network_policy = :public
193
+ original.allowed_cidrs = ['8.8.8.0/24']
194
+ original.connect_timeout_ms = 50
195
+ original.timeout_ms = 100
196
+
197
+ clone = original.clone
198
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
199
+ clone.perform
200
+ end
201
+
202
+ assert_match(/outside allowed CIDR ranges/, error.message)
203
+ assert_match(/1\.1\.1\.1/, error.message)
204
+ end
205
+
206
+ def test_allowed_cidrs_set_before_public_network_policy_are_enforced
207
+ require_public_network_policy!
208
+
209
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
210
+ easy.allowed_cidrs = ['8.8.8.0/24']
211
+ easy.network_policy = :public
212
+ easy.connect_timeout_ms = 50
213
+ easy.timeout_ms = 100
214
+
215
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
216
+ easy.perform
217
+ end
218
+
219
+ assert_match(/outside allowed CIDR ranges/, error.message)
220
+ assert_match(/1\.1\.1\.1/, error.message)
221
+ end
222
+
223
+ def test_public_network_policy_blocks_loopback_destination
224
+ easy = Curl::Easy.new(TestServlet.url)
225
+ require_public_network_policy!(easy)
226
+
227
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
228
+ easy.perform
229
+ end
230
+
231
+ assert_match(/127\.0\.0\.1/, error.message)
232
+ assert_match(/public network policy/, error.message)
233
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
234
+ end
235
+
236
+ def test_public_network_policy_does_not_reuse_pre_policy_loopback_connection
237
+ easy = Curl::Easy.new(TestServlet.url)
238
+
239
+ easy.perform
240
+ assert_equal 200, easy.response_code
241
+ assert_match(/GET/, easy.body_str)
242
+
243
+ require_public_network_policy!(easy)
244
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
245
+ easy.perform
246
+ end
247
+
248
+ assert_match(/127\.0\.0\.1/, error.message)
249
+ end
250
+
251
+ def test_public_network_policy_reuse_controls_clear_when_policy_is_disabled
252
+ omit('CURLOPT_FRESH_CONNECT is not available') unless Curl.const_defined?(:CURLOPT_FRESH_CONNECT)
253
+ omit('CURLOPT_FORBID_REUSE is not available') unless Curl.const_defined?(:CURLOPT_FORBID_REUSE)
254
+
255
+ previous_autoclose = Curl::Multi.autoclose
256
+ Curl::Multi.autoclose = false
257
+
258
+ easy = Curl::Easy.new(TestServlet.url)
259
+ require_public_network_policy!(easy)
260
+
261
+ assert_raise(Curl::Err::UnsafeDestinationError) do
262
+ easy.perform
263
+ end
264
+
265
+ easy.network_policy = :none
266
+ easy.perform
267
+ assert_equal 1, easy.num_connects
268
+
269
+ easy.perform
270
+ assert_equal 0, easy.num_connects
271
+ ensure
272
+ easy.close if defined?(easy) && easy
273
+ Curl::Multi.autoclose = previous_autoclose if defined?(previous_autoclose)
274
+ end
275
+
276
+ def test_default_network_policy_preserves_explicit_forbid_reuse
277
+ omit('CURLOPT_FORBID_REUSE is not available') unless Curl.const_defined?(:CURLOPT_FORBID_REUSE)
278
+
279
+ previous_autoclose = Curl::Multi.autoclose
280
+ Curl::Multi.autoclose = false
281
+
282
+ easy = Curl::Easy.new(TestServlet.url)
283
+ easy.setopt(Curl::CURLOPT_FORBID_REUSE, 1)
284
+
285
+ easy.perform
286
+ assert_equal 1, easy.num_connects
287
+
288
+ easy.perform
289
+ assert_equal 1, easy.num_connects
290
+ ensure
291
+ easy.close if defined?(easy) && easy
292
+ Curl::Multi.autoclose = previous_autoclose if defined?(previous_autoclose)
293
+ end
294
+
295
+ def test_public_network_policy_disable_preserves_explicit_forbid_reuse
296
+ omit('CURLOPT_FORBID_REUSE is not available') unless Curl.const_defined?(:CURLOPT_FORBID_REUSE)
297
+
298
+ previous_autoclose = Curl::Multi.autoclose
299
+ Curl::Multi.autoclose = false
300
+
301
+ easy = Curl::Easy.new(TestServlet.url)
302
+ easy.setopt(Curl::CURLOPT_FORBID_REUSE, 1)
303
+ require_public_network_policy!(easy)
304
+
305
+ assert_raise(Curl::Err::UnsafeDestinationError) do
306
+ easy.perform
307
+ end
308
+
309
+ easy.network_policy = :none
310
+ easy.perform
311
+ assert_equal 1, easy.num_connects
312
+
313
+ easy.perform
314
+ assert_equal 1, easy.num_connects
315
+ ensure
316
+ easy.close if defined?(easy) && easy
317
+ Curl::Multi.autoclose = previous_autoclose if defined?(previous_autoclose)
318
+ end
319
+
320
+ def test_public_network_policy_blocks_ipv4_unsafe_matrix
321
+ require_public_network_policy!
322
+
323
+ unsafe_addresses = {
324
+ 'unspecified' => '0.0.0.0',
325
+ 'private_10' => '10.0.0.1',
326
+ 'private_172' => '172.16.0.1',
327
+ 'private_192' => '192.168.0.1',
328
+ 'shared_carrier' => '100.64.0.1',
329
+ 'cloud_metadata' => '100.100.100.200',
330
+ 'loopback' => '127.0.0.1',
331
+ 'link_local_metadata' => '169.254.169.254',
332
+ 'ietf_assignments' => '192.0.0.8',
333
+ 'benchmarking' => '198.18.0.1',
334
+ 'documentation_1' => '192.0.2.1',
335
+ 'documentation_2' => '198.51.100.1',
336
+ 'documentation_3' => '203.0.113.1',
337
+ 'multicast' => '224.0.0.1',
338
+ 'reserved' => '240.0.0.1'
339
+ }
340
+
341
+ unsafe_addresses.each do |label, address|
342
+ error = assert_resolved_destination_blocked(address, label)
343
+
344
+ assert_match(/#{Regexp.escape(address)}/, error.message)
345
+ end
346
+ end
347
+
348
+ def test_public_network_policy_blocks_ipv6_unsafe_matrix
349
+ require_public_network_policy!
350
+ omit('IPv6 sockets are not available on this platform') unless Socket.const_defined?(:AF_INET6)
351
+
352
+ unsafe_addresses = {
353
+ 'unspecified' => '::',
354
+ 'loopback' => '::1',
355
+ 'unique_local_fc' => 'fc00::1',
356
+ 'unique_local_fd' => 'fd12:3456::1',
357
+ 'link_local' => 'fe80::1',
358
+ 'site_local' => 'fec0::1',
359
+ 'multicast' => 'ff02::1',
360
+ 'documentation' => '2001:db8::1',
361
+ 'benchmarking' => '2001:2::1',
362
+ 'six_to_four' => '2002::1',
363
+ 'ipv4_mapped_loopback' => '::ffff:127.0.0.1',
364
+ 'ipv4_mapped_private' => '::ffff:10.0.0.1',
365
+ 'ipv4_mapped_metadata' => '::ffff:169.254.169.254',
366
+ 'ipv4_compatible_private' => '::192.168.0.1'
367
+ }
368
+
369
+ unsafe_addresses.each do |label, address|
370
+ assert_resolved_destination_blocked(address, label)
371
+ end
372
+ end
373
+
374
+ def test_public_network_policy_blocks_nat64_well_known_embedded_unsafe_ipv4_peers
375
+ require_public_network_policy!
376
+ omit('IPv6 sockets are not available on this platform') unless Socket.const_defined?(:AF_INET6)
377
+
378
+ unsafe_addresses = {
379
+ 'nat64_well_known_loopback' => '64:ff9b::7f00:1',
380
+ 'nat64_well_known_private' => '64:ff9b::a00:1'
381
+ }
382
+
383
+ unsafe_addresses.each do |label, address|
384
+ error = assert_resolved_destination_blocked(address, label)
385
+
386
+ assert_match(/#{Regexp.escape(address)}/, error.message)
387
+ end
388
+ end
389
+
390
+ def test_public_network_policy_blocks_nat64_local_use_translation_prefix
391
+ require_public_network_policy!
392
+ omit('IPv6 sockets are not available on this platform') unless Socket.const_defined?(:AF_INET6)
393
+
394
+ address = '64:ff9b:1::203:4'
395
+ error = assert_resolved_destination_blocked(address, 'nat64_local_use')
396
+
397
+ assert_match(/#{Regexp.escape(address)}/, error.message)
398
+ end
399
+
400
+ def test_public_network_policy_uses_canonical_ipv6_diagnostics
401
+ require_public_network_policy!
402
+ omit('IPv6 sockets are not available on this platform') unless Socket.const_defined?(:AF_INET6)
403
+
404
+ error = assert_resolved_destination_blocked('2001:db8::1', 'documentation_canonical')
405
+
406
+ assert_match(/2001:db8::1/, error.message)
407
+ refute_match(/2001:0db8:0000:0000:0000:0000:0000:0001/, error.message)
408
+ end
409
+
410
+ def test_public_network_policy_allows_representative_ipv4_public_peer
411
+ assert_public_destination_not_blocked('1.1.1.1')
412
+ end
413
+
414
+ def test_public_network_policy_allowed_cidrs_allows_matching_public_peer
415
+ require_public_network_policy!
416
+
417
+ Curl.safe! do |config|
418
+ config.network_policy = :public
419
+ config.allowed_cidrs = ['1.1.1.0/24']
420
+ end
421
+
422
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
423
+ easy.connect_timeout_ms = 50
424
+ easy.timeout_ms = 100
425
+
426
+ perform_without_unsafe_destination(easy, '1.1.1.1')
427
+ ensure
428
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
429
+ end
430
+
431
+ def test_public_network_policy_allowed_cidrs_blocks_public_peer_outside_allowlist
432
+ require_public_network_policy!
433
+
434
+ Curl.safe! do |config|
435
+ config.network_policy = :public
436
+ config.allowed_cidrs = ['8.8.8.0/24']
437
+ end
438
+
439
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
440
+ easy.connect_timeout_ms = 50
441
+ easy.timeout_ms = 100
442
+
443
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
444
+ easy.perform
445
+ end
446
+
447
+ assert_match(/outside allowed CIDR ranges/, error.message)
448
+ assert_match(/1\.1\.1\.1/, error.message)
449
+ ensure
450
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
451
+ end
452
+
453
+ def test_public_network_policy_allowed_cidrs_do_not_permit_unsafe_peer
454
+ require_public_network_policy!
455
+
456
+ Curl.safe! do |config|
457
+ config.network_policy = :public
458
+ config.allowed_cidrs = ['127.0.0.0/8']
459
+ end
460
+
461
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
462
+ Curl.get(TestServlet.url)
463
+ end
464
+
465
+ assert_match(/unsafe destination/, error.message)
466
+ assert_match(/127\.0\.0\.1/, error.message)
467
+ ensure
468
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
469
+ end
470
+
471
+ def test_public_network_policy_allows_representative_ipv6_public_peer
472
+ omit('IPv6 sockets are not available on this platform') unless Socket.const_defined?(:AF_INET6)
473
+
474
+ assert_public_destination_not_blocked('2606:4700:4700::1111')
475
+ end
476
+
477
+ def test_public_network_policy_allowed_cidrs_allows_matching_ipv6_public_peer
478
+ require_public_network_policy!
479
+ omit('IPv6 sockets are not available on this platform') unless Socket.const_defined?(:AF_INET6)
480
+
481
+ Curl.safe! do |config|
482
+ config.network_policy = :public
483
+ config.allowed_cidrs = ['2606:4700:4700::/48']
484
+ end
485
+
486
+ easy = Curl::Easy.new('http://[2606:4700:4700::1111]:81/')
487
+ easy.connect_timeout_ms = 50
488
+ easy.timeout_ms = 100
489
+
490
+ perform_without_unsafe_destination(easy, '2606:4700:4700::1111')
491
+ ensure
492
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
493
+ end
494
+
495
+ def test_safe_bang_public_network_policy_blocks_loopback_destination
496
+ require_public_network_policy!
497
+
498
+ Curl.safe! do |config|
499
+ config.network_policy = :public
500
+ end
501
+
502
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
503
+ Curl.get(TestServlet.url)
504
+ end
505
+
506
+ assert_match(/127\.0\.0\.1/, error.message)
507
+ end
508
+
509
+ def test_public_network_policy_disables_explicit_proxy
510
+ require_public_network_policy!
511
+
512
+ Curl.safe! do |config|
513
+ config.network_policy = :public
514
+ end
515
+
516
+ easy = Curl::Easy.new(TestServlet.url)
517
+ easy.proxy_url = 'http://127.0.0.1:3128'
518
+ easy.proxy_tunnel = true
519
+
520
+ Curl.__send__(:apply_safety!, easy)
521
+
522
+ assert_equal '', easy.proxy_url
523
+ assert_equal false, easy.proxy_tunnel?
524
+ end
525
+
526
+ def test_public_network_policy_allows_explicit_proxy_host_allowlist
527
+ require_public_network_policy!
528
+
529
+ Curl.safe! do |config|
530
+ config.network_policy = :public
531
+ config.allowed_proxy_hosts = ['proxy.example:3128']
532
+ end
533
+
534
+ easy = Curl::Easy.new('http://1.1.1.1/')
535
+ easy.proxy_url = 'http://user:pass@PROXY.example.:3128'
536
+
537
+ Curl.__send__(:apply_safety!, easy)
538
+
539
+ assert_equal 'http://user:pass@PROXY.example.:3128', easy.proxy_url
540
+ end
541
+
542
+ def test_direct_public_network_policy_ignores_explicit_proxy
543
+ proxy_server = TCPServer.new('127.0.0.1', 0)
544
+ proxy_port = proxy_server.addr[1]
545
+ proxy_connections = []
546
+ proxy_thread = record_proxy_connections(proxy_server, proxy_connections)
547
+
548
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
549
+ require_public_network_policy!(easy)
550
+ easy.proxy_url = "http://127.0.0.1:#{proxy_port}"
551
+ easy.proxy_tunnel = true
552
+ easy.connect_timeout_ms = 50
553
+ easy.timeout_ms = 100
554
+
555
+ perform_without_unsafe_destination(easy, '1.1.1.1')
556
+ sleep 0.05
557
+
558
+ assert_nil easy.unsafe_destination_error
559
+ assert_empty proxy_connections, 'direct public network policy should disable explicit proxies'
560
+ ensure
561
+ proxy_server.close if defined?(proxy_server) && proxy_server
562
+ proxy_thread.join(1) if defined?(proxy_thread) && proxy_thread
563
+ end
564
+
565
+ def test_public_network_policy_rejects_explicit_proxy_outside_allowlist
566
+ require_public_network_policy!
567
+
568
+ Curl.safe! do |config|
569
+ config.network_policy = :public
570
+ config.allowed_proxy_hosts = ['proxy.example']
571
+ end
572
+
573
+ easy = Curl::Easy.new('http://1.1.1.1/')
574
+ easy.proxy_url = 'http://blocked.example:3128'
575
+
576
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
577
+ Curl.__send__(:apply_safety!, easy)
578
+ end
579
+
580
+ assert_match(/proxy allowlist/, error.message)
581
+ assert_match(/blocked\.example/, error.message)
582
+ end
583
+
584
+ def test_public_network_policy_proxy_allowlist_still_ignores_environment_proxy_without_explicit_proxy
585
+ require_public_network_policy!
586
+ proxy_server = TCPServer.new('127.0.0.1', 0)
587
+ proxy_port = proxy_server.addr[1]
588
+ proxy_connections = []
589
+ proxy_thread = record_proxy_connections(proxy_server, proxy_connections)
590
+
591
+ with_proxy_environment("http://127.0.0.1:#{proxy_port}") do
592
+ Curl.safe! do |config|
593
+ config.network_policy = :public
594
+ config.allowed_proxy_hosts = ['proxy.example']
595
+ end
596
+
597
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
598
+ easy.connect_timeout_ms = 50
599
+ easy.timeout_ms = 100
600
+
601
+ perform_without_unsafe_destination(easy, '1.1.1.1')
602
+ sleep 0.05
603
+
604
+ assert_equal '', easy.proxy_url
605
+ assert_empty proxy_connections, 'proxy allowlist should not permit environment proxies without an explicit proxy'
606
+ end
607
+ ensure
608
+ proxy_server.close if defined?(proxy_server) && proxy_server
609
+ proxy_thread.join(1) if defined?(proxy_thread) && proxy_thread
610
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
611
+ end
612
+
613
+ def test_direct_public_network_policy_ignores_proxy_environment_variables
614
+ proxy_server = TCPServer.new('127.0.0.1', 0)
615
+ proxy_port = proxy_server.addr[1]
616
+ proxy_connections = []
617
+ proxy_thread = record_proxy_connections(proxy_server, proxy_connections)
618
+
619
+ with_proxy_environment("http://127.0.0.1:#{proxy_port}") do
620
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
621
+ require_public_network_policy!(easy)
622
+ easy.connect_timeout_ms = 50
623
+ easy.timeout_ms = 100
624
+
625
+ perform_without_unsafe_destination(easy, '1.1.1.1')
626
+ sleep 0.05
627
+
628
+ assert_nil easy.unsafe_destination_error
629
+ assert_empty proxy_connections, 'direct public network policy should disable environment proxies'
630
+ end
631
+ ensure
632
+ proxy_server.close if defined?(proxy_server) && proxy_server
633
+ proxy_thread.join(1) if defined?(proxy_thread) && proxy_thread
634
+ end
635
+
636
+ def test_public_network_policy_ignores_proxy_environment_variables
637
+ require_public_network_policy!
638
+ proxy_server = TCPServer.new('127.0.0.1', 0)
639
+ proxy_port = proxy_server.addr[1]
640
+ proxy_connections = []
641
+ proxy_thread = record_proxy_connections(proxy_server, proxy_connections)
642
+
643
+ with_proxy_environment("http://127.0.0.1:#{proxy_port}") do
644
+ Curl.safe! do |config|
645
+ config.network_policy = :public
646
+ end
647
+
648
+ easy = Curl::Easy.new('http://1.1.1.1:81/')
649
+ easy.connect_timeout_ms = 50
650
+ easy.timeout_ms = 100
651
+
652
+ perform_without_unsafe_destination(easy, '1.1.1.1')
653
+ sleep 0.05
654
+
655
+ assert_nil easy.unsafe_destination_error
656
+ assert_empty proxy_connections, 'public network policy should disable environment proxies'
657
+ end
658
+ ensure
659
+ proxy_server.close if defined?(proxy_server) && proxy_server
660
+ proxy_thread.join(1) if defined?(proxy_thread) && proxy_thread
661
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
662
+ end
663
+
664
+ def test_public_network_policy_rejects_resolve_override
665
+ require_public_network_policy!
666
+
667
+ Curl.safe! do |config|
668
+ config.network_policy = :public
669
+ end
670
+
671
+ easy = Curl::Easy.new(TestServlet.url)
672
+ easy.resolve = ["example.test:80:127.0.0.1"]
673
+
674
+ assert_raise(Curl::Err::UnsafeDestinationError) do
675
+ Curl.__send__(:apply_safety!, easy)
676
+ end
677
+ end
678
+
679
+ def test_public_network_policy_can_allow_resolve_override_before_peer_check
680
+ require_public_network_policy!
681
+
682
+ Curl.safe! do |config|
683
+ config.network_policy = :public
684
+ config.allow_resolve = true
685
+ end
686
+
687
+ easy = Curl::Easy.new(TestServlet.url)
688
+ easy.resolve = ["example.test:80:127.0.0.1"]
689
+
690
+ Curl.__send__(:apply_safety!, easy)
691
+
692
+ assert_equal ["example.test:80:127.0.0.1"], easy.resolve
693
+ end
694
+
695
+ def test_public_network_policy_rejects_connect_to_override
696
+ omit('CURLOPT_CONNECT_TO not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_CONNECT_TO)
697
+ require_public_network_policy!
698
+
699
+ Curl.safe! do |config|
700
+ config.network_policy = :public
701
+ end
702
+
703
+ easy = Curl::Easy.new('http://example.test/')
704
+ easy.connect_to = ['example.test:80:127.0.0.1:80']
705
+
706
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
707
+ Curl.__send__(:apply_safety!, easy)
708
+ end
709
+
710
+ assert_match(/connect_to overrides/, error.message)
711
+ end
712
+
713
+ def test_public_network_policy_can_allow_connect_to_override_before_peer_check
714
+ omit('CURLOPT_CONNECT_TO not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_CONNECT_TO)
715
+ require_public_network_policy!
716
+
717
+ Curl.safe! do |config|
718
+ config.network_policy = :public
719
+ config.allow_connect_to = true
720
+ end
721
+
722
+ mapping = ['example.test:80:127.0.0.1:80']
723
+ easy = Curl::Easy.new('http://example.test/')
724
+ easy.connect_to = mapping
725
+
726
+ Curl.__send__(:apply_safety!, easy)
727
+
728
+ assert_equal mapping, easy.connect_to
729
+ end
730
+
731
+ def test_public_network_policy_blocks_connect_to_private_peer_when_override_is_allowed
732
+ omit('CURLOPT_CONNECT_TO not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_CONNECT_TO)
733
+ require_public_network_policy!
734
+
735
+ Curl.safe! do |config|
736
+ config.network_policy = :public
737
+ config.allow_connect_to = true
738
+ end
739
+
740
+ host = 'curb-connect-to.invalid'
741
+ port = TestServlet.port
742
+ easy = Curl::Easy.new("http://#{host}:#{port}#{TestServlet.path}")
743
+ easy.connect_to = ["#{host}:#{port}:127.0.0.1:#{port}"]
744
+ easy.connect_timeout_ms = 50
745
+
746
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
747
+ easy.perform
748
+ end
749
+
750
+ assert_match(/127\.0\.0\.1/, error.message)
751
+ end
752
+
753
+ def test_public_network_policy_rejects_doh_url_override
754
+ omit('CURLOPT_DOH_URL not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_URL)
755
+ require_public_network_policy!
756
+
757
+ Curl.safe! do |config|
758
+ config.network_policy = :public
759
+ end
760
+
761
+ easy = Curl::Easy.new('http://example.test/')
762
+ easy.doh_url = 'https://dns.example/dns-query'
763
+
764
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
765
+ Curl.__send__(:apply_safety!, easy)
766
+ end
767
+
768
+ assert_match(/DoH URL overrides/, error.message)
769
+ end
770
+
771
+ def test_public_network_policy_rejects_dns_servers_override
772
+ omit('CURLOPT_DNS_SERVERS not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DNS_SERVERS)
773
+ require_public_network_policy!
774
+
775
+ easy = Curl::Easy.new('http://example.test/')
776
+ easy.network_policy = :public
777
+ easy.setopt(Curl::CURLOPT_DNS_SERVERS, '127.0.0.1')
778
+
779
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
780
+ easy.perform
781
+ end
782
+
783
+ assert_match(/DNS server overrides/, error.message)
784
+ end
785
+
786
+ def test_public_network_policy_can_allow_doh_url_override_before_peer_check
787
+ omit('CURLOPT_DOH_URL not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_URL)
788
+ require_public_network_policy!
789
+
790
+ Curl.safe! do |config|
791
+ config.network_policy = :public
792
+ config.allow_doh = true
793
+ end
794
+
795
+ easy = Curl::Easy.new('http://example.test/')
796
+ easy.doh_url = 'https://dns.example/dns-query'
797
+
798
+ Curl.__send__(:apply_safety!, easy)
799
+
800
+ assert_equal 'https://dns.example/dns-query', easy.doh_url
801
+ end
802
+
803
+ def test_doh_url_nil_clears_native_doh_url
804
+ omit('CURLOPT_DOH_URL not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_URL)
805
+ omit('CURLOPT_DOH_SSL_VERIFYPEER not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_SSL_VERIFYPEER)
806
+ omit('CURLOPT_DOH_SSL_VERIFYHOST not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_DOH_SSL_VERIFYHOST)
807
+
808
+ host = 'curb-doh-clear.invalid'
809
+ with_rebinding_doh_server(host, '127.0.0.1') do |doh_url, doh_state|
810
+ easy = Curl::Easy.new("http://#{host}:81/")
811
+ configure_live_doh_easy(easy, doh_url)
812
+ easy.doh_url = nil
813
+
814
+ begin
815
+ easy.perform
816
+ rescue Curl::Err::CurlError
817
+ nil
818
+ end
819
+
820
+ assert_equal [], doh_a_answers(doh_state)
821
+ end
822
+ end
823
+
824
+ def test_public_network_policy_blocks_resolved_private_peer_when_resolve_override_is_allowed
825
+ require_public_network_policy!
826
+
827
+ Curl.safe! do |config|
828
+ config.network_policy = :public
829
+ config.allow_resolve = true
830
+ end
831
+
832
+ host = 'curb-dns-rebind.invalid'
833
+ easy = Curl::Easy.new("http://#{host}:80/")
834
+ easy.resolve = [resolve_entry(host, 80, '127.0.0.1')]
835
+ easy.connect_timeout_ms = 50
836
+
837
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
838
+ easy.perform
839
+ end
840
+
841
+ assert_match(/127\.0\.0\.1/, error.message)
842
+ end
843
+
844
+ def test_public_network_policy_rechecks_same_host_when_resolved_address_changes
845
+ require_public_network_policy!
846
+
847
+ Curl.safe! do |config|
848
+ config.network_policy = :public
849
+ config.allow_resolve = true
850
+ end
851
+
852
+ host = 'curb-dns-rebind.invalid'
853
+ easy = Curl::Easy.new("http://#{host}:81/")
854
+ easy.connect_timeout_ms = 50
855
+ easy.timeout_ms = 100
856
+
857
+ easy.resolve = [resolve_entry(host, 81, '1.1.1.1')]
858
+ perform_without_unsafe_destination(easy, '1.1.1.1')
859
+ assert_nil easy.unsafe_destination_error
860
+
861
+ easy.resolve = [resolve_entry(host, 81, '127.0.0.1')]
862
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
863
+ easy.perform
864
+ end
865
+
866
+ assert_match(/127\.0\.0\.1/, error.message)
867
+ end
868
+
869
+ def test_live_public_redirect_to_private_peer_is_blocked_by_resolved_peer_policy
870
+ live_network_required!
871
+ require_public_network_policy!
872
+
873
+ with_private_target_server('/private-redirect-target') do |port, hits|
874
+ private_host = 'curb-private-redirect.invalid'
875
+ private_url = "http://#{private_host}:#{port}/private-redirect-target"
876
+
877
+ Curl.safe! do |config|
878
+ config.network_policy = :public
879
+ config.allow_resolve = true
880
+ end
881
+
882
+ easy = Curl::Easy.new(live_redirect_url_for(private_url))
883
+ easy.follow_location = true
884
+ easy.resolve = [resolve_entry(private_host, port, '127.0.0.1')]
885
+ easy.connect_timeout_ms = live_connect_timeout_ms
886
+ easy.timeout_ms = live_timeout_ms
887
+
888
+ error = nil
889
+ begin
890
+ easy.perform
891
+ rescue Curl::Err::UnsafeDestinationError => e
892
+ error = e
893
+ rescue Curl::Err::CurlError => e
894
+ if easy.respond_to?(:redirect_count) && easy.redirect_count.to_i == 0
895
+ omit("live redirect endpoint unavailable before redirect: #{e.class}: #{e.message}")
896
+ end
897
+
898
+ flunk("expected redirected private peer to be reported unsafe, got #{e.class}: #{e.message}")
899
+ end
900
+
901
+ assert_equal 0, hits[:target], 'private redirect target should not receive a request'
902
+ if error.nil? && easy.respond_to?(:redirect_count) && easy.redirect_count.to_i == 0
903
+ omit("live redirect endpoint did not issue a redirect, response code #{easy.response_code}")
904
+ end
905
+
906
+ assert_not_nil error, 'expected redirected private peer to be blocked'
907
+ assert_match(/public network policy/, error.message)
908
+ assert_match(/127\.0\.0\.1/, error.message)
909
+ end
910
+ ensure
911
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
912
+ end
913
+
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)
918
+ require_public_network_policy!
919
+
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)
942
+
943
+ error = assert_raise(Curl::Err::UnsafeDestinationError) do
944
+ second.perform
945
+ end
946
+
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)
950
+ end
951
+ ensure
952
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
953
+ end
954
+
955
+ def test_public_network_policy_rejects_unix_socket_override
956
+ omit('CURLOPT_UNIX_SOCKET_PATH not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_UNIX_SOCKET_PATH)
957
+ require_public_network_policy!
958
+
959
+ Curl.safe! do |config|
960
+ config.network_policy = :public
961
+ end
962
+
963
+ easy = Curl::Easy.new(TestServlet.url)
964
+ easy.setopt(Curl::CURLOPT_UNIX_SOCKET_PATH, '/tmp/curb-test.sock')
965
+
966
+ assert_raise(Curl::Err::UnsafeDestinationError) do
967
+ Curl.__send__(:apply_safety!, easy)
968
+ end
969
+ end
970
+
971
+ def test_public_network_policy_can_allow_unix_socket_override
972
+ omit('CURLOPT_UNIX_SOCKET_PATH not supported by this libcurl') unless Curl.const_defined?(:CURLOPT_UNIX_SOCKET_PATH)
973
+ omit('Unix socket transfers are not supported on Windows runners') if WINDOWS
974
+ omit('Unix domain sockets are not available on this platform') unless defined?(UNIXServer)
975
+ require_public_network_policy!
976
+ require 'tmpdir'
977
+
978
+ socket_path = File.join(Dir.tmpdir, "curb-test-#{$$}-#{object_id}.sock")
979
+ FileUtils.rm_f(socket_path)
980
+ server = UNIXServer.new(socket_path)
981
+ server_thread = Thread.new do
982
+ socket = server.accept
983
+ begin
984
+ socket.readpartial(4096)
985
+ rescue EOFError
986
+ end
987
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nunix socket")
988
+ socket.close
989
+ end
990
+
991
+ Curl.safe! do |config|
992
+ config.network_policy = :public
993
+ config.allow_unix_socket = true
994
+ end
995
+
996
+ easy = Curl::Easy.new('http://unix.test/')
997
+ easy.setopt(Curl::CURLOPT_UNIX_SOCKET_PATH, socket_path)
998
+ easy.connect_timeout_ms = 500
999
+ easy.timeout_ms = 1_000
1000
+ easy.perform
1001
+
1002
+ assert_equal 200, easy.response_code
1003
+ assert_equal 'unix socket', easy.body_str
1004
+ ensure
1005
+ server.close if defined?(server) && server && !server.closed?
1006
+ if defined?(server_thread) && server_thread
1007
+ server_thread.join(1)
1008
+ server_thread.kill if server_thread.alive?
1009
+ end
1010
+ FileUtils.rm_f(socket_path) if defined?(socket_path) && socket_path
1011
+ end
1012
+
1013
+ def test_multi_records_public_network_policy_block
1014
+ easy = Curl::Easy.new(TestServlet.url)
1015
+ require_public_network_policy!(easy)
1016
+ multi = Curl::Multi.new
1017
+
1018
+ multi.add(easy)
1019
+ multi.perform
1020
+
1021
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
1022
+ ensure
1023
+ multi.close if defined?(multi) && multi
1024
+ end
1025
+
1026
+ def test_multi_reinstalls_public_policy_callbacks_when_safe_enabled_after_add
1027
+ require_public_network_policy!
1028
+ easy = Curl::Easy.new(TestServlet.url)
1029
+ multi = Curl::Multi.new
1030
+
1031
+ multi.add(easy)
1032
+ Curl.safe! do |config|
1033
+ config.network_policy = :public
1034
+ end
1035
+ multi.perform
1036
+
1037
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
1038
+ assert_equal 7, easy.last_result
1039
+ ensure
1040
+ multi.close if defined?(multi) && multi
1041
+ end
1042
+
1043
+ def test_multi_reinstalls_public_policy_callbacks_when_easy_policy_enabled_after_add
1044
+ easy = Curl::Easy.new(TestServlet.url)
1045
+ multi = Curl::Multi.new
1046
+
1047
+ multi.add(easy)
1048
+ require_public_network_policy!(easy)
1049
+ multi.perform
1050
+
1051
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
1052
+ assert_equal 7, easy.last_result
1053
+ ensure
1054
+ multi.close if defined?(multi) && multi
1055
+ end
1056
+
1057
+ def test_multi_reinstalls_host_allowlist_callback_when_allowed_hosts_set_after_add
1058
+ omit('redirect-aware host allowlists require CURLOPT_PREREQFUNCTION') unless Curl.const_defined?(:CURLOPT_PREREQFUNCTION)
1059
+
1060
+ blocked_host = 'curb-late-blocked.test'
1061
+ easy = Curl::Easy.new("http://#{blocked_host}:#{TestServlet.port}#{TestServlet.path}")
1062
+ easy.resolve = [resolve_entry(blocked_host, TestServlet.port, '127.0.0.1')]
1063
+ multi = Curl::Multi.new
1064
+
1065
+ multi.add(easy)
1066
+ easy.allowed_hosts = ['curb-late-allowed.test']
1067
+ multi.perform
1068
+
1069
+ assert_match(/host allowlist/, easy.unsafe_destination_error)
1070
+ assert_match(/#{Regexp.escape(blocked_host)}/, easy.unsafe_destination_error)
1071
+ assert_not_equal 0, easy.last_result
1072
+ ensure
1073
+ multi.close if defined?(multi) && multi
1074
+ end
1075
+
1076
+ def test_multi_safety_refresh_does_not_duplicate_slist_backed_options
1077
+ with_header_recording_server do |url, request_headers|
1078
+ easy = Curl::Easy.new(url)
1079
+ easy.headers['X-Test'] = '1'
1080
+ multi = Curl::Multi.new
1081
+
1082
+ multi.add(easy)
1083
+ Curl.safe!
1084
+ multi.perform
1085
+
1086
+ assert_equal [['1']], request_headers
1087
+ ensure
1088
+ multi.close if defined?(multi) && multi
1089
+ end
1090
+ end
1091
+
1092
+ def test_multi_does_not_reuse_pre_policy_loopback_connection
1093
+ easy = Curl::Easy.new(TestServlet.url)
1094
+ multi = Curl::Multi.new
1095
+
1096
+ multi.add(easy)
1097
+ multi.perform
1098
+ assert_equal 200, easy.response_code
1099
+ assert_match(/GET/, easy.body_str)
1100
+
1101
+ require_public_network_policy!(easy)
1102
+ multi.add(easy)
1103
+ multi.perform
1104
+
1105
+ assert_match(/127\.0\.0\.1/, easy.unsafe_destination_error)
1106
+ assert_equal 7, easy.last_result
1107
+ ensure
1108
+ multi.close if defined?(multi) && multi
1109
+ end
1110
+
1111
+ def test_multi_isolates_allowed_and_blocked_public_policy_handles
1112
+ allowed = Curl::Easy.new(TestServlet.url)
1113
+ blocked = Curl::Easy.new(TestServlet.url)
1114
+ require_public_network_policy!(blocked)
1115
+ multi = Curl::Multi.new
1116
+ events = []
1117
+
1118
+ allowed.on_complete { events << [:allowed, :complete] }
1119
+ allowed.on_success do |easy|
1120
+ events << [:allowed, :success, easy.response_code]
1121
+ end
1122
+ allowed.on_failure do |_easy, error|
1123
+ events << [:allowed, :failure, error.first]
1124
+ end
1125
+
1126
+ blocked.on_complete { events << [:blocked, :complete] }
1127
+ blocked.on_success do |easy|
1128
+ events << [:blocked, :success, easy.response_code]
1129
+ end
1130
+ blocked.on_failure do |_easy, error|
1131
+ events << [:blocked, :failure, error.first]
1132
+ end
1133
+
1134
+ multi.add(allowed)
1135
+ multi.add(blocked)
1136
+ multi.perform
1137
+
1138
+ assert_equal 200, allowed.response_code
1139
+ assert_match(/GET/, allowed.body_str)
1140
+ assert_nil allowed.unsafe_destination_error
1141
+ assert_match(/127\.0\.0\.1/, blocked.unsafe_destination_error)
1142
+ assert_equal 7, blocked.last_result
1143
+
1144
+ assert_equal [:allowed, :complete], events.find { |event| event[0] == :allowed && event[1] == :complete }
1145
+ assert_equal [:allowed, :success, 200], events.find { |event| event[0] == :allowed && event[1] == :success }
1146
+ assert_nil events.find { |event| event[0] == :allowed && event[1] == :failure }
1147
+ assert_equal [:blocked, :complete], events.find { |event| event[0] == :blocked && event[1] == :complete }
1148
+ assert_equal [:blocked, :failure, Curl::Err::ConnectionFailedError],
1149
+ events.find { |event| event[0] == :blocked && event[1] == :failure }
1150
+ assert_nil events.find { |event| event[0] == :blocked && event[1] == :success }
1151
+ ensure
1152
+ multi.close if defined?(multi) && multi
1153
+ end
1154
+
1155
+ private
1156
+
1157
+ def require_public_network_policy!(easy = Curl::Easy.new)
1158
+ easy.network_policy = :public
1159
+ easy
1160
+ rescue NotImplementedError
1161
+ omit('network_policy=:public requires CURLOPT_OPENSOCKETFUNCTION support')
1162
+ end
1163
+
1164
+ def assert_resolved_destination_blocked(address, label)
1165
+ host = "curb-#{label.tr('_', '-')}.invalid"
1166
+ easy = Curl::Easy.new("http://#{host}:80/")
1167
+ require_public_network_policy!(easy)
1168
+ easy.resolve = [resolve_entry(host, 80, address)]
1169
+ easy.connect_timeout_ms = 50
1170
+
1171
+ error = assert_raise(Curl::Err::UnsafeDestinationError, "expected #{address} to be blocked") do
1172
+ easy.perform
1173
+ end
1174
+
1175
+ assert_match(/public network policy/, error.message)
1176
+ assert_match(/public network policy/, easy.unsafe_destination_error)
1177
+ error
1178
+ end
1179
+
1180
+ def resolve_entry(host, port, address)
1181
+ resolved_address = address.include?(':') ? "[#{address}]" : address
1182
+ "#{host}:#{port}:#{resolved_address}"
1183
+ end
1184
+
1185
+ def assert_public_destination_not_blocked(address)
1186
+ require_public_network_policy!
1187
+
1188
+ Curl.safe! do |config|
1189
+ config.network_policy = :public
1190
+ end
1191
+
1192
+ host = address.include?(':') ? "[#{address}]" : address
1193
+ easy = Curl::Easy.new("http://#{host}:81/")
1194
+ easy.connect_timeout_ms = 50
1195
+ easy.timeout_ms = 100
1196
+
1197
+ perform_without_unsafe_destination(easy, address)
1198
+ rescue Curl::Err::UnsafeDestinationError => e
1199
+ flunk("expected #{address} to be treated as public, got #{e.class}: #{e.message}")
1200
+ ensure
1201
+ Curl.__send__(:clear_safe!) if Curl.respond_to?(:clear_safe!, true)
1202
+ end
1203
+
1204
+ def perform_without_unsafe_destination(easy, address)
1205
+ easy.perform
1206
+ rescue Curl::Err::UnsafeDestinationError => e
1207
+ flunk("expected #{address} to be treated as public, got #{e.class}: #{e.message}")
1208
+ rescue Curl::Err::CurlError
1209
+ assert_nil easy.unsafe_destination_error
1210
+ end
1211
+
1212
+ def record_proxy_connections(server, connections)
1213
+ Thread.new do
1214
+ loop do
1215
+ readable = IO.select([server], nil, nil, 0.05)
1216
+ next unless readable
1217
+
1218
+ socket = server.accept_nonblock(exception: false)
1219
+ next unless socket.is_a?(TCPSocket)
1220
+
1221
+ connections << true
1222
+ socket.close
1223
+ rescue IOError, Errno::EBADF, Errno::ENOTSOCK
1224
+ break
1225
+ end
1226
+ end
1227
+ end
1228
+
1229
+ def with_proxy_environment(proxy_url)
1230
+ keys = %w[http_proxy HTTP_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY]
1231
+ old_values = keys.each_with_object({}) { |key, values| values[key] = ENV[key] }
1232
+
1233
+ ENV['http_proxy'] = proxy_url
1234
+ ENV['HTTP_PROXY'] = proxy_url
1235
+ ENV['all_proxy'] = proxy_url
1236
+ ENV['ALL_PROXY'] = proxy_url
1237
+ ENV.delete('no_proxy')
1238
+ ENV.delete('NO_PROXY')
1239
+
1240
+ yield
1241
+ ensure
1242
+ old_values.each do |key, value|
1243
+ if value.nil?
1244
+ ENV.delete(key)
1245
+ else
1246
+ ENV[key] = value
1247
+ end
1248
+ end
1249
+ end
1250
+
1251
+ def live_network_required!
1252
+ omit('set CURB_LIVE_NETWORK_TESTS=1 to run live network security tests') unless ENV['CURB_LIVE_NETWORK_TESTS'] == '1'
1253
+ end
1254
+
1255
+ def live_redirect_url_for(target_url)
1256
+ template = ENV['CURB_LIVE_REDIRECT_URL_TEMPLATE'] || 'https://httpbin.org/redirect-to?url={url}'
1257
+ omit('CURB_LIVE_REDIRECT_URL_TEMPLATE must include {url}') unless template.include?('{url}')
1258
+
1259
+ template.gsub('{url}', URI.encode_www_form_component(target_url))
1260
+ end
1261
+
1262
+ def live_connect_timeout_ms
1263
+ [(ENV['CURB_LIVE_CONNECT_TIMEOUT_MS'] || 1_000).to_i, 1].max
1264
+ end
1265
+
1266
+ def live_timeout_ms
1267
+ [(ENV['CURB_LIVE_TIMEOUT_MS'] || 5_000).to_i, 1].max
1268
+ end
1269
+
1270
+ def configure_live_doh_easy(easy, doh_url)
1271
+ easy.resolve_mode = :ipv4
1272
+ easy.dns_cache_timeout = 0
1273
+ easy.connect_timeout_ms = live_connect_timeout_ms
1274
+ easy.timeout_ms = live_timeout_ms
1275
+ easy.doh_url = doh_url
1276
+ easy.setopt(Curl::CURLOPT_DOH_SSL_VERIFYPEER, 0)
1277
+ easy.setopt(Curl::CURLOPT_DOH_SSL_VERIFYHOST, 0)
1278
+ end
1279
+
1280
+ def with_rebinding_doh_server(host, initial_address)
1281
+ require 'webrick/https'
1282
+ require 'openssl'
1283
+
1284
+ omit('DoH rebinding fixture requires OpenSSL') unless defined?(OpenSSL::PKey::RSA)
1285
+
1286
+ port_socket = TCPServer.new('127.0.0.1', 0)
1287
+ port = port_socket.addr[1]
1288
+ port_socket.close
1289
+
1290
+ expected_host = host.downcase
1291
+ state = {
1292
+ :address => initial_address,
1293
+ :answers => [],
1294
+ :queries => [],
1295
+ :mutex => Mutex.new
1296
+ }
1297
+
1298
+ cert, key = doh_certificate
1299
+ server = WEBrick::HTTPServer.new(:BindAddress => '127.0.0.1',
1300
+ :Port => port,
1301
+ :Logger => WEBRICK_TEST_LOG,
1302
+ :AccessLog => [],
1303
+ :SSLEnable => true,
1304
+ :SSLCertificate => cert,
1305
+ :SSLPrivateKey => key)
1306
+ server.mount_proc('/dns-query') do |req, res|
1307
+ res['Content-Type'] = 'application/dns-message'
1308
+ res.body = dns_response(req.body.b, expected_host, state)
1309
+ end
1310
+
1311
+ thread = Thread.new(server) { |srv| srv.start }
1312
+ wait_for_server_ready(port, thread: thread)
1313
+
1314
+ yield "https://127.0.0.1:#{port}/dns-query", state
1315
+ ensure
1316
+ server.shutdown if defined?(server) && server
1317
+ thread.join(server_startup_timeout) if defined?(thread) && thread
1318
+ thread.kill if defined?(thread) && thread&.alive?
1319
+ end
1320
+
1321
+ def set_doh_answer(state, address)
1322
+ state[:mutex].synchronize { state[:address] = address }
1323
+ end
1324
+
1325
+ def doh_a_answers(state)
1326
+ state[:mutex].synchronize { state[:answers].dup }
1327
+ end
1328
+
1329
+ def doh_certificate
1330
+ key = OpenSSL::PKey::RSA.new(2048)
1331
+ cert = OpenSSL::X509::Certificate.new
1332
+ cert.version = 2
1333
+ cert.serial = 1
1334
+ cert.subject = OpenSSL::X509::Name.parse('/CN=127.0.0.1')
1335
+ cert.issuer = cert.subject
1336
+ cert.public_key = key.public_key
1337
+ cert.not_before = Time.now - 60
1338
+ cert.not_after = Time.now + 3600
1339
+
1340
+ extensions = OpenSSL::X509::ExtensionFactory.new
1341
+ extensions.subject_certificate = cert
1342
+ extensions.issuer_certificate = cert
1343
+ cert.add_extension(extensions.create_extension('basicConstraints', 'CA:FALSE', true))
1344
+ cert.add_extension(extensions.create_extension('subjectAltName', 'IP:127.0.0.1', false))
1345
+ cert.sign(key, OpenSSL::Digest::SHA256.new)
1346
+
1347
+ [cert, key]
1348
+ end
1349
+
1350
+ def dns_response(packet, expected_host, state)
1351
+ question = dns_question(packet)
1352
+ return nil unless question
1353
+
1354
+ name, qtype, qclass, question_bytes = question
1355
+ answer_address = nil
1356
+ state[:mutex].synchronize do
1357
+ state[:queries] << [name, qtype, qclass]
1358
+ if name == expected_host && qtype == 1 && qclass == 1
1359
+ answer_address = state[:address]
1360
+ state[:answers] << answer_address
1361
+ end
1362
+ end
1363
+
1364
+ answer = answer_address && dns_a_answer(answer_address)
1365
+ [packet.unpack1('n'), 0x8180, 1, answer ? 1 : 0, 0, 0].pack('nnnnnn') +
1366
+ question_bytes +
1367
+ (answer || ''.b)
1368
+ end
1369
+
1370
+ def dns_question(packet)
1371
+ return nil if packet.bytesize < 12
1372
+
1373
+ offset = 12
1374
+ labels = []
1375
+ loop do
1376
+ length = packet.getbyte(offset)
1377
+ return nil unless length
1378
+
1379
+ offset += 1
1380
+ break if length.zero?
1381
+ return nil if length & 0xc0 != 0
1382
+ return nil if offset + length > packet.bytesize
1383
+
1384
+ labels << packet.byteslice(offset, length)
1385
+ offset += length
1386
+ end
1387
+
1388
+ return nil if offset + 4 > packet.bytesize
1389
+
1390
+ qtype, qclass = packet.byteslice(offset, 4).unpack('nn')
1391
+ [labels.join('.').downcase, qtype, qclass, packet.byteslice(12, offset + 4 - 12)]
1392
+ end
1393
+
1394
+ def dns_a_answer(address)
1395
+ octets = address.split('.').map { |octet| Integer(octet, 10) }
1396
+ return nil unless octets.length == 4 && octets.all? { |octet| octet.between?(0, 255) }
1397
+
1398
+ [0xc00c, 1, 1, 0, 4].pack('nnnNn') + octets.pack('C4')
1399
+ end
1400
+
1401
+ def with_private_target_server(path)
1402
+ port_socket = TCPServer.new('127.0.0.1', 0)
1403
+ port = port_socket.addr[1]
1404
+ port_socket.close
1405
+
1406
+ hits = Hash.new(0)
1407
+ server = WEBrick::HTTPServer.new(:BindAddress => '127.0.0.1', :Port => port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
1408
+ server.mount_proc(path) do |_req, res|
1409
+ hits[:target] += 1
1410
+ res['Content-Type'] = 'text/plain'
1411
+ res.body = 'private target'
1412
+ end
1413
+
1414
+ server_thread = Thread.new(server) { |srv| srv.start }
1415
+ wait_for_server_ready(port, thread: server_thread)
1416
+
1417
+ yield port, hits
1418
+ ensure
1419
+ server.shutdown if defined?(server) && server
1420
+ server_thread.join(server_startup_timeout) if defined?(server_thread) && server_thread
1421
+ server_thread.kill if defined?(server_thread) && server_thread&.alive?
1422
+ end
1423
+
1424
+ def with_host_redirect_server(_allowed_host, target_host)
1425
+ port_socket = TCPServer.new('127.0.0.1', 0)
1426
+ port = port_socket.addr[1]
1427
+ port_socket.close
1428
+
1429
+ hits = Hash.new(0)
1430
+ server = WEBrick::HTTPServer.new(:BindAddress => '127.0.0.1', :Port => port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
1431
+ server.mount_proc('/redirect-to-target') do |_req, res|
1432
+ hits[:redirect] += 1
1433
+ res.status = 302
1434
+ res['Location'] = "http://#{target_host}:#{port}/target"
1435
+ res.body = 'redirect'
1436
+ end
1437
+ server.mount_proc('/target') do |_req, res|
1438
+ hits[:target] += 1
1439
+ res['Content-Type'] = 'text/plain'
1440
+ res.body = 'target'
1441
+ end
1442
+
1443
+ server_thread = Thread.new(server) { |srv| srv.start }
1444
+ wait_for_server_ready(port, thread: server_thread)
1445
+
1446
+ yield port, hits
1447
+ ensure
1448
+ server.shutdown if defined?(server) && server
1449
+ server_thread.join(server_startup_timeout) if defined?(server_thread) && server_thread
1450
+ server_thread.kill if defined?(server_thread) && server_thread&.alive?
1451
+ end
1452
+
1453
+ def with_header_recording_server
1454
+ port_socket = TCPServer.new('127.0.0.1', 0)
1455
+ port = port_socket.addr[1]
1456
+ port_socket.close
1457
+
1458
+ request_headers = []
1459
+ server = WEBrick::HTTPServer.new(:BindAddress => '127.0.0.1', :Port => port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
1460
+ server.mount_proc('/headers') do |req, res|
1461
+ request_headers << req.header.fetch('x-test', []).dup
1462
+ res['Content-Type'] = 'text/plain'
1463
+ res.body = 'ok'
1464
+ end
1465
+
1466
+ server_thread = Thread.new(server) { |srv| srv.start }
1467
+ wait_for_server_ready(port, thread: server_thread)
1468
+
1469
+ yield "http://127.0.0.1:#{port}/headers", request_headers
1470
+ ensure
1471
+ server.shutdown if defined?(server) && server
1472
+ server_thread.join(server_startup_timeout) if defined?(server_thread) && server_thread
1473
+ server_thread.kill if defined?(server_thread) && server_thread&.alive?
1474
+ end
1475
+ end