patron 0.13.3 → 0.14.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.
@@ -6,15 +6,25 @@ require 'fileutils'
6
6
 
7
7
  describe Patron::Session do
8
8
 
9
+ def yaml_load(str)
10
+ if RUBY_VERSION >= '3.1.0'
11
+ YAML::safe_load(str, permitted_classes: [OpenStruct])
12
+ else
13
+ YAML::load(str)
14
+ end
15
+ end
16
+
9
17
  before(:each) do
10
18
  @session = Patron::Session.new
11
19
  @session.base_url = "https://localhost:9043"
12
20
  @session.insecure = true
21
+ # The first TLS handshake takes a few seconds when both ends run under valgrind
22
+ @session.connect_timeout = 10
13
23
  end
14
24
 
15
25
  it "should retrieve a url with :get" do
16
26
  response = @session.get("/test")
17
- body = YAML::load(response.body)
27
+ body = yaml_load(response.body)
18
28
  expect(body.request_method).to be == "GET"
19
29
  end
20
30
 
@@ -22,7 +32,7 @@ describe Patron::Session do
22
32
  tmpfile = "/tmp/patron_test.yaml"
23
33
  response = @session.get_file "/test", tmpfile
24
34
  expect(response.body).to be_nil
25
- body = YAML::load_file(tmpfile)
35
+ body = yaml_load(File.open(tmpfile).read)
26
36
  expect(body.request_method).to be == "GET"
27
37
  FileUtils.rm tmpfile
28
38
  end
@@ -39,7 +49,7 @@ describe Patron::Session do
39
49
  pid = fork do
40
50
  response = @session.get_file "/test", tmpfile
41
51
  expect(response.body).to be_nil
42
- body = YAML::load_file(tmpfile)
52
+ body = yaml_load(File.open(tmpfile).read)
43
53
  expect(body.request_method).to be == "GET"
44
54
  FileUtils.rm tmpfile
45
55
  end
@@ -58,14 +68,14 @@ describe Patron::Session do
58
68
 
59
69
  it "should include custom headers in a request" do
60
70
  response = @session.get("/test", {"User-Agent" => "PatronTest"})
61
- body = YAML::load(response.body)
71
+ body = yaml_load(response.body)
62
72
  expect(body.header["user-agent"]).to be == ["PatronTest"]
63
73
  end
64
74
 
65
75
  it "should merge custom headers with session headers" do
66
76
  @session.headers["X-Test"] = "Testing"
67
77
  response = @session.get("/test", {"User-Agent" => "PatronTest"})
68
- body = YAML::load(response.body)
78
+ body = yaml_load(response.body)
69
79
  expect(body.header["user-agent"]).to be == ["PatronTest"]
70
80
  expect(body.header["x-test"]).to be == ["Testing"]
71
81
  end
@@ -80,7 +90,7 @@ describe Patron::Session do
80
90
  it "should follow redirects by default" do
81
91
  @session.max_redirects = 1
82
92
  response = @session.get("/redirect")
83
- body = YAML::load(response.body)
93
+ body = yaml_load(response.body)
84
94
  expect(response.status).to be == 200
85
95
  expect(body.path).to be == "/test"
86
96
  end
@@ -107,26 +117,26 @@ describe Patron::Session do
107
117
 
108
118
  it "should send a delete request with :delete" do
109
119
  response = @session.delete("/test")
110
- body = YAML::load(response.body)
120
+ body = yaml_load(response.body)
111
121
  expect(body.request_method).to be == "DELETE"
112
122
  end
113
123
 
114
124
  it "should send a COPY request with :copy" do
115
125
  response = @session.copy("/test", "/test2")
116
- body = YAML::load(response.body)
126
+ body = yaml_load(response.body)
117
127
  expect(body.request_method).to be == "COPY"
118
128
  end
119
129
 
120
130
  it "should include a Destination header in COPY requests" do
121
131
  response = @session.copy("/test", "/test2")
122
- body = YAML::load(response.body)
132
+ body = yaml_load(response.body)
123
133
  expect(body.header['destination'].first).to be == "/test2"
124
134
  end
125
135
 
126
136
  it "should upload data with :get" do
127
137
  data = "upload data"
128
138
  response = @session.request(:get, "/test", {}, :data => data)
129
- body = YAML::load(response.body)
139
+ body = yaml_load(response.body)
130
140
  expect(body.request_method).to be == "GET"
131
141
  expect(body.header['content-length']).to be == [data.size.to_s]
132
142
  end
@@ -134,7 +144,7 @@ describe Patron::Session do
134
144
  it "should upload data with :put" do
135
145
  data = "upload data"
136
146
  response = @session.put("/test", data)
137
- body = YAML::load(response.body)
147
+ body = yaml_load(response.body)
138
148
  expect(body.request_method).to be == "PUT"
139
149
  expect(body.header['content-length']).to be == [data.size.to_s]
140
150
  end
@@ -145,7 +155,7 @@ describe Patron::Session do
145
155
 
146
156
  it "should upload a file with :put" do
147
157
  response = @session.put_file("/test", "LICENSE")
148
- body = YAML::load(response.body)
158
+ body = yaml_load(response.body)
149
159
  expect(body.request_method).to be == "PUT"
150
160
  end
151
161
 
@@ -153,16 +163,27 @@ describe Patron::Session do
153
163
  expect { @session.put_file("/test", nil) }.to raise_error(ArgumentError)
154
164
  end
155
165
 
156
- it "should use chunked encoding when uploading a file with :put" do
157
- response = @session.put_file("/test", "LICENSE")
158
- body = YAML::load(response.body)
159
- expect(body.header['transfer-encoding'].first).to be == "chunked"
166
+ it "should upload a file with :put using chunked transfer-encoding" do
167
+ # Transfer-Encoding is a hop-by-hop header (RFC 7230 3.3.1): a conformant server
168
+ # decodes the chunked framing and never exposes the header to the application, so
169
+ # we capture the raw request to assert how Patron actually frames the upload.
170
+ captured = capture_request(ssl: true) do |url|
171
+ session = Patron::Session.new
172
+ session.base_url = url
173
+ session.insecure = true
174
+ session.timeout = 5
175
+ session.put_file("/test", "LICENSE")
176
+ end
177
+ expect(captured.request_line).to start_with("PUT ")
178
+ expect(captured.headers["transfer-encoding"]).to be == "chunked"
179
+ expect(captured.headers).to_not have_key("content-length")
180
+ expect(captured.body.bytesize).to be == File.size("LICENSE")
160
181
  end
161
182
 
162
183
  it "should upload data with :post" do
163
184
  data = "upload data"
164
185
  response = @session.post("/test", data)
165
- body = YAML::load(response.body)
186
+ body = yaml_load(response.body)
166
187
  expect(body.request_method).to be == "POST"
167
188
  expect(body.header['content-length']).to be == [data.size.to_s]
168
189
  end
@@ -170,7 +191,7 @@ describe Patron::Session do
170
191
  it "should post a hash of arguments as a urlencoded form" do
171
192
  data = {:foo => 123, 'baz' => '++hello world++'}
172
193
  response = @session.post("/testpost", data)
173
- body = YAML::load(response.body)
194
+ body = yaml_load(response.body)
174
195
  expect(body['content_type']).to be == "application/x-www-form-urlencoded"
175
196
  expect(body['body']).to match(/baz=%2B%2Bhello%20world%2B%2B/)
176
197
  expect(body['body']).to match(/foo=123/)
@@ -182,13 +203,13 @@ describe Patron::Session do
182
203
 
183
204
  it "should upload a file with :post" do
184
205
  response = @session.post_file("/test", "LICENSE")
185
- body = YAML::load(response.body)
206
+ body = yaml_load(response.body)
186
207
  expect(body.request_method).to be == "POST"
187
208
  end
188
209
 
189
210
  it "should upload a multipart with :post" do
190
211
  response = @session.post_multipart("/test", { :test_data => "123" }, { :test_file => "LICENSE" } )
191
- body = YAML::load(response.body)
212
+ body = yaml_load(response.body)
192
213
  expect(body.request_method).to be == "POST"
193
214
  end
194
215
 
@@ -196,21 +217,31 @@ describe Patron::Session do
196
217
  expect { @session.post_file("/test", nil) }.to raise_error(ArgumentError)
197
218
  end
198
219
 
199
- it "should use chunked encoding when uploading a file with :post" do
200
- response = @session.post_file("/test", "LICENSE")
201
- body = YAML::load(response.body)
202
- expect(body.header['transfer-encoding'].first).to be == "chunked"
220
+ it "should upload a file with :post using chunked transfer-encoding" do
221
+ # See the :put counterpart above: Transfer-Encoding is hop-by-hop and invisible
222
+ # to the application, so we capture the raw request to assert the framing.
223
+ captured = capture_request(ssl: true) do |url|
224
+ session = Patron::Session.new
225
+ session.base_url = url
226
+ session.insecure = true
227
+ session.timeout = 5
228
+ session.post_file("/test", "LICENSE")
229
+ end
230
+ expect(captured.request_line).to start_with("POST ")
231
+ expect(captured.headers["transfer-encoding"]).to be == "chunked"
232
+ expect(captured.headers).to_not have_key("content-length")
233
+ expect(captured.body.bytesize).to be == File.size("LICENSE")
203
234
  end
204
235
 
205
236
  it "should handle cookies if set" do
206
237
  @session.handle_cookies
207
238
  response = @session.get("/setcookie").body
208
- expect(YAML::load(response).header['cookie'].first).to be == "session_id=foo123"
239
+ expect(yaml_load(response).header['cookie'].first).to be == "session_id=foo123"
209
240
  end
210
241
 
211
242
  it "should not handle cookies by default" do
212
243
  response = @session.get("/setcookie").body
213
- expect(YAML::load(response).header).to_not include('cookie')
244
+ expect(yaml_load(response).header).to_not include('cookie')
214
245
  end
215
246
 
216
247
  it "should raise exception if cookie store is not writable or readable" do
@@ -241,10 +272,20 @@ describe Patron::Session do
241
272
  @session.insecure = nil
242
273
  @session.cacert = File.join(__dir__, 'support', 'certs', 'cacert.pem')
243
274
  response = @session.get("/test")
244
- body = YAML::load(response.body)
275
+ body = yaml_load(response.body)
245
276
  expect(body.request_method).to be == "GET"
246
277
  end
247
278
 
279
+ it "should allow to specify insecure mode per-request" do
280
+ @session.insecure = false
281
+ expect {
282
+ @session.request(:get, "/test", {}, insecure: true)
283
+ }.not_to raise_error
284
+ expect {
285
+ @session.request(:get, "/test", {})
286
+ }.to raise_error(Patron::Error)
287
+ end
288
+
248
289
  it "should work with different SSL versions" do
249
290
  ['TLSv1_0','TLSv1_1'].each do |version|
250
291
  @session.ssl_version = version
@@ -292,4 +333,99 @@ describe Patron::Session do
292
333
  end
293
334
  end
294
335
 
336
+ describe 'with a server that requires a client certificate' do
337
+ let(:certs) { File.join(__dir__, 'support', 'certs') }
338
+
339
+ # libcurl's GnuTLS backend reports any client certificate it can't load as CURLE_SSL_CONNECT_ERROR
340
+ let(:gnutls) { Patron.libcurl_version.include?('GnuTLS') }
341
+
342
+ def refused_by_server
343
+ raise_error { |e|
344
+ expect(e).to be_kind_of(Patron::Error)
345
+ expect(e).not_to be_kind_of(Patron::ClientCertificateError)
346
+ expect(e).not_to be_kind_of(Patron::TimeoutError)
347
+ }
348
+ end
349
+
350
+ before(:each) do
351
+ @session.base_url = "https://localhost:9044"
352
+ @session.insecure = false
353
+ @session.cacert = File.join(certs, 'cacert.pem')
354
+ end
355
+
356
+ it "presents a PKCS#12 client certificate" do
357
+ @session.ssl_cert = File.join(certs, 'client.p12')
358
+ @session.ssl_cert_type = 'P12'
359
+ @session.ssl_key_password = 'patron'
360
+ response = @session.get("/peercert")
361
+ expect(response.status).to be == 200
362
+ expect(response.body).to include('CN=patron-test-client')
363
+ end
364
+
365
+ it "presents a PEM client certificate with a separate encrypted key" do
366
+ @session.ssl_cert = File.join(certs, 'client_cert.pem')
367
+ @session.ssl_cert_type = 'PEM'
368
+ @session.ssl_key = File.join(certs, 'client_key.pem')
369
+ @session.ssl_key_password = 'patron'
370
+ response = @session.get("/peercert")
371
+ expect(response.status).to be == 200
372
+ expect(response.body).to include('CN=patron-test-client')
373
+ end
374
+
375
+ it "accepts the client certificate as a per-request option" do
376
+ response = @session.request(:get, "/peercert", {},
377
+ ssl_cert: File.join(certs, 'client.p12'), ssl_cert_type: 'P12', ssl_key_password: 'patron')
378
+ expect(response.body).to include('CN=patron-test-client')
379
+ end
380
+
381
+ it "accepts Pathnames for the certificate and key and a Symbol for the type" do
382
+ @session.cacert = Pathname(certs).join('cacert.pem')
383
+ @session.ssl_cert = Pathname(certs).join('client_cert.pem')
384
+ @session.ssl_cert_type = :PEM
385
+ @session.ssl_key = Pathname(certs).join('client_key.pem')
386
+ @session.ssl_key_password = 'patron'
387
+ response = @session.get("/peercert")
388
+ expect(response.body).to include('CN=patron-test-client')
389
+ end
390
+
391
+ it "does not leak a failed request's options into the next one" do
392
+ @session.ssl_cert = File.join(certs, 'client.p12')
393
+ @session.ssl_cert_type = 'P12'
394
+ @session.ssl_key_password = 'patron'
395
+ expect {
396
+ @session.request(:get, "/test", {'X-Leaked' => 'yes'}, ssl_key_password: 1234)
397
+ }.to raise_error(TypeError)
398
+
399
+ body = yaml_load(@session.get("/test").body)
400
+ expect(body.header).not_to have_key('x-leaked')
401
+ end
402
+
403
+ it "raises ClientCertificateError when the key password is wrong" do
404
+ skip "GnuTLS does not tell this apart from a handshake failure" if gnutls
405
+ @session.ssl_cert = File.join(certs, 'client.p12')
406
+ @session.ssl_cert_type = 'P12'
407
+ @session.ssl_key_password = 'wrong'
408
+ expect { @session.get("/peercert") }.to raise_error(Patron::ClientCertificateError)
409
+ end
410
+
411
+ it "raises ClientCertificateError when the certificate file does not exist" do
412
+ skip "GnuTLS does not tell this apart from a handshake failure" if gnutls
413
+ @session.ssl_cert = File.join(certs, 'nonexistent.p12')
414
+ @session.ssl_cert_type = 'P12'
415
+ @session.ssl_key_password = 'patron'
416
+ expect { @session.get("/peercert") }.to raise_error(Patron::ClientCertificateError)
417
+ end
418
+
419
+ it "fails when the server does not trust the client certificate" do
420
+ @session.ssl_cert = File.join(certs, 'rogue_client.p12')
421
+ @session.ssl_cert_type = 'P12'
422
+ @session.ssl_key_password = 'patron'
423
+ expect { @session.get("/peercert") }.to refused_by_server
424
+ end
425
+
426
+ it "fails when no client certificate is presented" do
427
+ # Depending on the TLS version the server rejects us either during or right after the handshake
428
+ expect { @session.get("/peercert") }.to refused_by_server
429
+ end
430
+ end
295
431
  end
data/spec/spec_helper.rb CHANGED
@@ -18,14 +18,23 @@ $stderr.puts "Build against #{Patron.libcurl_version}"
18
18
 
19
19
  Dir['./spec/support/**/*.rb'].each { |fn| require fn }
20
20
 
21
- http_server_pid = Process.fork { PatronTestServer.start(false, 9001) }
22
- https_server_pid = Process.fork { PatronTestServer.start(true, 9043) }
21
+ http_server = Fork.new { PatronTestServer.start(false, 9001) }
22
+
23
+ sleep 0.1 # Don't interfere the start up output of two processes.
24
+
25
+ https_server = Fork.new { PatronTestServer.start(true, 9043) }
26
+
27
+ mtls_server = Fork.new do
28
+ PatronTestServer.start(true, 9044, client_ca: File.join(__dir__, 'support', 'certs', 'client_ca.pem'))
29
+ end
23
30
 
24
31
  RSpec.configure do |c|
25
32
  c.after(:suite) do
26
- Process.kill("INT", http_server_pid)
27
- Process.kill("INT", https_server_pid)
28
- Process.wait(http_server_pid)
29
- Process.wait(https_server_pid)
33
+ http_server.kill("TERM")
34
+ https_server.kill("TERM")
35
+ mtls_server.kill("TERM")
36
+ http_server.wait
37
+ https_server.wait
38
+ mtls_server.wait
30
39
  end
31
- end
40
+ end
Binary file
@@ -0,0 +1,20 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDWzCCAkOgAwIBAgIBATANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQGEwJVUzEg
3
+ MB4GA1UECgwXUGF0cm9uIFRlc3QgQ2VydGlmaWNhdGUxHjAcBgNVBAMMFVBhdHJv
4
+ biBUZXN0IENsaWVudCBDQTAeFw0yNTAxMDEwMDAwMDBaFw00NTAxMDEwMDAwMDBa
5
+ ME8xCzAJBgNVBAYTAlVTMSAwHgYDVQQKDBdQYXRyb24gVGVzdCBDZXJ0aWZpY2F0
6
+ ZTEeMBwGA1UEAwwVUGF0cm9uIFRlc3QgQ2xpZW50IENBMIIBIjANBgkqhkiG9w0B
7
+ AQEFAAOCAQ8AMIIBCgKCAQEA1iqOoIm5gW9MP0onsmSuOdlAdFZkP5r7rRSn+100
8
+ 5ceHDpRdaR+k8ZIoi7noesryrf7ibZL+9VzZEBGlCcUGEIjQjL/BHsHa5vLfAJnT
9
+ 0zbeQFk5MjGZOUPXC33CgidsxF3DsRFDNmOqGBiCf3A/ae/VOMDEyv6aFdKc6JQC
10
+ OoudnCGzVNJuLIa+/yGx9HhIjobbYGuZ7gwLjJk3HW9WixTupUKJME9f7cTIPW+k
11
+ ahw7ZQGzIUHejqy8cDnmBRYNpZ8NU5oTuhr5NFDWhOSGfM70NYFlGDifI2kMPhdc
12
+ s4nEUajG1b3NLBfKMM1HioqG2ivMQv6WNRWGJCn3sdJEXQIDAQABo0IwQDAPBgNV
13
+ HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUd1Pmdua66Xyn
14
+ qKIbXkVUC389WA8wDQYJKoZIhvcNAQELBQADggEBAD+6HGi8mdkHDnegma3AvQ0K
15
+ 4EoTtKljui93zVhsaXpycr/UtOe7p6jBApBdh93up6Fx9cYu2OtTLh0t9rnLXBWD
16
+ GZQt1jajEwvdRg2ncFfoWkxeftUttd5uTVMN6Apv3nGw3VXZnNmVaRyZnGG5j1JO
17
+ lnN94DfE37MyePhuFCDd5RZXegIsel62AMPJNV/RtwPf5b7T59f/Ol1oTMkaWEtM
18
+ wYU/2cFTqGjtqeTv7IewTERbqBxJcvIU3pNVHthq/jyiiuaLeAybbx1AZoI3fkY8
19
+ FO1LAtZx1/xZwjqtSuTZn8CbryLoMetpvVqsUlI72FxStQf5JAFjO+H4kWEEORU=
20
+ -----END CERTIFICATE-----
@@ -0,0 +1,20 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIDSzCCAjOgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQGEwJVUzEg
3
+ MB4GA1UECgwXUGF0cm9uIFRlc3QgQ2VydGlmaWNhdGUxHjAcBgNVBAMMFVBhdHJv
4
+ biBUZXN0IENsaWVudCBDQTAeFw0yNTAxMDEwMDAwMDBaFw00NTAxMDEwMDAwMDBa
5
+ MEwxCzAJBgNVBAYTAlVTMSAwHgYDVQQKDBdQYXRyb24gVGVzdCBDZXJ0aWZpY2F0
6
+ ZTEbMBkGA1UEAwwScGF0cm9uLXRlc3QtY2xpZW50MIIBIjANBgkqhkiG9w0BAQEF
7
+ AAOCAQ8AMIIBCgKCAQEAnp7kL8f5Yk/T6B+IRoLWvCTHeORa4ilp7ixTE4EJOwTB
8
+ atAfnoue4U+xMBAX91oIiGT8u5yKcC+EbvmvcOuOYQPCVXvx3bHBzeTzjBkH37k1
9
+ rBeXVxfhEnkXAANJOccTpPJRH2m+1X/FcXuUNKZOnSrc4G/Gpraj2Opygz8ntxbW
10
+ JR94mz1nkkAILlkCie4VFfSdnxE5ghQSnt8B7W9SdqSKD22NKpBmOff2x5136IEo
11
+ YNK7zYintgJfX6Pa7TR8peEVtAkl3/EjSEoSmfFQVZz2OB7IK3xFT2RyqoC9MjO6
12
+ 2mItbD0LgZzTD3f91g00a7z6EjSW8g8QYc5pXSdC1wIDAQABozUwMzAMBgNVHRMB
13
+ Af8EAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjANBgkq
14
+ hkiG9w0BAQsFAAOCAQEAU/u+MOKWMXQqkPSQx2RjrOale3KKdkHX6LlGfZYjqMhn
15
+ IQjYbEfGtIKyD2MChXrhKMLwuwvMUaCMQWq0r9vZb2cz+AGDS/OzHui/YXgoUU/D
16
+ JOqmT17ClM1JIBZjfbLaogGGY//l4NA48MjhTDHw9MGOnnjzVYUy7CC6qiFu0onC
17
+ Ko+ZE0Ebb3KyjUeJLSCXEhTVW0myGc+/jNRJP0YW/TvxKRZ5EAhaJrLXvKm7tjil
18
+ SZpXBETiXq6eZZsD+XNfQvytBIFO4eBhv3sipQ8Ga30L3SqkMlsb4ptWpa5+z1Vk
19
+ BlkuYdKpPhD/nmx/OfRdfTItDVstm8Q13+MGS5Rokw==
20
+ -----END CERTIFICATE-----
@@ -0,0 +1,30 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ Proc-Type: 4,ENCRYPTED
3
+ DEK-Info: AES-256-CBC,73A5C7F43CBFB22BDECED16E3F6D4014
4
+
5
+ s2DHo+hKzlo9ZFcql4V84w5c8OADkBHNYp3DJJSII+LSufiNLJ1JVcACEZdr/JsF
6
+ vLKTlCIjExGY1b85GPZ25PArB5jrZvWCcOFfs5PGzr3/ykRTfTMHSXo+WfFwkZ2x
7
+ 2k/Zt8yVJ7f69G7EevTtVbkmx/wLMnlTSjMKfzWfs/f57GP+dWM5rHOiMBKK2KhC
8
+ OlD6yhKV4frmF9YTQlslrg5Nmd+3UQsSubT6s4qLByYrLm7pJHB9mx2LNLLQnoxv
9
+ IBIL+FcQRQvLtGGbzV42BHEqr6Kq7aqDf9RT6JohOMdGMYMtABZP9/iOqCUMT2QB
10
+ 2/cio1jjgijHqpVBsPg9D493t0fm+1FXLK21wfvQgP6/v0wDB3YKlGlHzWWiVc2f
11
+ 4EF/VAyPR8bJwpnZvQpsQ16HEftyyHTbishVbiqzvTNi4HnmSX6WQI2pwFHlF22j
12
+ UWJzSfLqp8NW340zo/EYDAm/dPaqVGR+pVtUEta+oGSI7OQWB3hGt7znSpdgFrah
13
+ i1IOkcu3fie15FzSlBtFhByigtz6iedrTCvYZhfswU4XxJfPJtlVnDubQ9xKDXp6
14
+ 3xuy8R4ekMysSOGHq2NdOaYvOoMK8LAJ++KZwEe6UJfBDvEkC23V3mkoCKiUANDY
15
+ GuJ5SOqw6DF6cO/i+StvOuB+2mxMOhR4x893N/ntQrITYldvRh+QHNFoyKc5NUu4
16
+ 035ezDVRDR4kzJzzTFfO9yshGFrqb7oyfUegzZMGah1sNn7AWG7AtBSbxSzmRk5s
17
+ 0uBrBX7ASfMUGkpUV7BFsvDPi819k5iWLYLFPiFB/CJ5Wq0HHU4j9XDIa/dEuSe+
18
+ 0HvQpYf/2IiYocxKxnZOXfdO06cigip1OjxbIXgx1uqrCyzB7c7XMLEUXETpdj1p
19
+ XPCvZ/9BaRNjWvvTrNNB2nMSarFdtjY6pB8bZtP4xObiQ59L3mLoL9/MD87Cnhzn
20
+ Gr8Yr3V5TJk8o60rWNNpaXeCbIlJONFN128uAP8vgjaYviHNb15ViDW1a57fO/OW
21
+ 4XOt5jqVDC/NfQXADku7bcs1SPUOKbDgVeYiiq0na3t5yePNRZVcA6FEHuc9hDLi
22
+ T+Fp7bqxXa7pZMn9FOCJ0cRhtoD6LaIqNbtzCIU9u6HBbJkg36Nz7ysh3riYPKBI
23
+ ShLhSb+unI2owdWBi6QwDRus2FtcvPwPNrqtnZ1V5/YyFY6k7ScLR6R67A4ESKNJ
24
+ qIWQ0iCN9nCjbCGpO62e5BzwoVGXiTQK7QK1WPT95+E3Qlv8q6hGi32d5gMUNR1h
25
+ r8OmL+HXMZE2azKJB1mKldYznOYiSIJDjvSp/Jm4A+6zYB6wwiQWwZUUrmW7zbg0
26
+ o7PHImDvRj8hkJlpNRCqLKkyKF+/PaqkFx2dJxJT3xmVJ+H7wqmwhii1Y0RE9YHu
27
+ yzrL7vZpC2a2cjyJnlDG7sX4ZLPNg1vOEXcnKO/6+HHDLkmPQj85+mqHInrTvuca
28
+ GqC/JZeD8FMnpsWQC51ZwThbwlzq91kvFPhSxOqEequ0HP/1K3DdNhxQNKwQseV2
29
+ 0jK0rCgibSroD8bE7kghtUPK/fts6XGRqgXmPmx9i3VJGbJw14GvKGp3oyosVEna
30
+ -----END RSA PRIVATE KEY-----
Binary file
@@ -67,7 +67,7 @@ TimeoutServlet = Proc.new {|env|
67
67
  SlowServlet = Proc.new {|env|
68
68
  body = Enumerator.new do |y|
69
69
  y.yield 'x'
70
- sleep 20
70
+ sleep 5
71
71
  y.yield 'rest of body'
72
72
  end
73
73
  [200, {'Content-Type' => 'text/plain'}, body]
@@ -102,7 +102,13 @@ RepetitiveHeaderServlet = Proc.new {|env|
102
102
  }
103
103
 
104
104
  PictureServlet = Proc.new {|env|
105
- [200, {'Content-Type' => 'image/png'}, [File.read('./pic.png')]]
105
+ # Rack::File allows us to test Range support as well
106
+ env_with_adjusted_path = env.merge('PATH_INFO' => Rack::Utils.escape('/pic.png'))
107
+ Rack::File.new('./').call(env_with_adjusted_path)
108
+ }
109
+
110
+ RedirectToPictureServlet = Proc.new {|env|
111
+ [307, {'Location' => '/picture'}, []]
106
112
  }
107
113
 
108
114
  WrongContentLengthServlet = Proc.new {|env|
@@ -111,15 +117,22 @@ WrongContentLengthServlet = Proc.new {|env|
111
117
 
112
118
  # Serves a substantial amount of data
113
119
  LargeServlet = Proc.new {|env|
120
+ rng = Random.new
114
121
  len = 15 * 1024 * 1024
115
122
  body = Enumerator.new do |y|
116
123
  15.times do
117
- y.yield(Random.new.bytes(1024 * 1024))
124
+ y.yield(rng.bytes(1024 * 1024))
118
125
  end
119
126
  end
120
127
  [200, {'Content-Type' => 'binary/octet-stream', 'Content-Length' => len.to_s}, body]
121
128
  }
122
129
 
130
+ # Echoes the subject of the client certificate Puma verified, if any
131
+ PeerCertServlet = Proc.new {|env|
132
+ subject = env['puma.peercert'] ? env['puma.peercert'].subject.to_s : 'none'
133
+ [200, {'Content-Type' => 'text/plain', 'Content-Length' => subject.bytesize.to_s}, [subject]]
134
+ }
135
+
123
136
  run Rack::URLMap.new({
124
137
  "/" => Proc.new {|env| [200, {'Content-Length' => '2'}, ['Welcome']]},
125
138
  "/test" => Readback,
@@ -130,9 +143,11 @@ run Rack::URLMap.new({
130
143
  "/redirect" => RedirectServlet,
131
144
  "/evil-redirect" => EvilRedirectServlet,
132
145
  "/picture" => PictureServlet,
146
+ "/redirect-to-picture" => RedirectToPictureServlet,
133
147
  "/very-large" => LargeServlet,
134
148
  "/setcookie" => SetCookieServlet,
135
149
  "/repetitiveheader" => RepetitiveHeaderServlet,
136
150
  "/wrongcontentlength" => WrongContentLengthServlet,
137
151
  "/gzip-compressed" => GzipServlet,
152
+ "/peercert" => PeerCertServlet,
138
153
  })
@@ -0,0 +1,70 @@
1
+ require 'thread'
2
+
3
+ class Fork
4
+ def initialize(&block)
5
+ @mu = Mutex.new
6
+ @cond = ConditionVariable.new
7
+ @pstate = nil
8
+ @pid = Process.fork(&block)
9
+ @killed = false
10
+
11
+ # Start monitoring the PID.
12
+ Thread.new { monitor }
13
+
14
+ # Kill the process anyway when the program exits.
15
+ ppid = Process.pid
16
+ at_exit do
17
+ if ppid == Process.pid # Make sure we are not inside another fork spawned by rspec example.
18
+ do_kill("KILL")
19
+ end
20
+ end
21
+ end
22
+
23
+ # Wait for process to exit.
24
+ def wait(timeout = nil)
25
+ @mu.synchronize do
26
+ next @pstate unless @pstate.nil?
27
+
28
+ @cond.wait(@mu, timeout)
29
+ @pstate
30
+ end
31
+ end
32
+
33
+ # Signal the process.
34
+ def kill(sig)
35
+ already_killed = @mu.synchronize do
36
+ old = @killed
37
+ @killed = true
38
+ old
39
+ end
40
+ signaled = do_kill(sig)
41
+ Thread.new { reaper } if signaled && !already_killed
42
+ signaled
43
+ end
44
+
45
+ private
46
+
47
+ # Signal the process.
48
+ def do_kill(sig)
49
+ Process.kill(sig, @pid)
50
+ true
51
+ rescue Errno::ESRCH # No such process
52
+ false
53
+ end
54
+
55
+ # Monitor the process state.
56
+ def monitor
57
+ _, pstate = Process.wait2(@pid)
58
+
59
+ @mu.synchronize do
60
+ @pstate = pstate
61
+ @cond.broadcast
62
+ end
63
+ end
64
+
65
+ # Wait 500 milliseconds and force terminate.
66
+ def reaper
67
+ pstate = wait(0.5)
68
+ do_kill("KILL") unless pstate
69
+ end
70
+ end
@@ -0,0 +1,93 @@
1
+ require 'socket'
2
+ require 'openssl'
3
+ require 'stringio'
4
+
5
+ # Captures the raw HTTP request a client puts on the wire, independent of any
6
+ # Rack/HTTP server. This lets us assert on hop-by-hop details such as
7
+ # Transfer-Encoding which a conformant server (webrick, puma, ...) decodes and
8
+ # never exposes to the application.
9
+ module RawRequestCapture
10
+ CapturedRequest = Struct.new(:request_line, :headers, :body)
11
+
12
+ CERT_DIR = File.join(__dir__, 'certs')
13
+
14
+ # Boots a one-shot capture server on an ephemeral port, yields its base URL to
15
+ # the block (which must perform a single request against it) and returns the
16
+ # CapturedRequest. Pass ssl: true to terminate TLS using the test certificates.
17
+ def capture_request(ssl: false)
18
+ tcp = TCPServer.new('127.0.0.1', 0)
19
+ port = tcp.addr[1]
20
+ server = ssl ? wrap_tls(tcp) : tcp
21
+ captured = nil
22
+ acceptor = Thread.new do
23
+ conn = server.accept
24
+ captured = read_request(conn)
25
+ conn.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
26
+ conn.close
27
+ end
28
+ yield "#{ssl ? 'https' : 'http'}://127.0.0.1:#{port}"
29
+ acceptor.join(5)
30
+ captured
31
+ ensure
32
+ tcp.close if tcp
33
+ end
34
+
35
+ private
36
+
37
+ def wrap_tls(tcp)
38
+ ctx = OpenSSL::SSL::SSLContext.new
39
+ ctx.cert = OpenSSL::X509::Certificate.new(File.read(File.join(CERT_DIR, 'cacert.pem')))
40
+ ctx.key = OpenSSL::PKey.read(File.read(File.join(CERT_DIR, 'privkey.pem')))
41
+ OpenSSL::SSL::SSLServer.new(tcp, ctx)
42
+ end
43
+
44
+ def read_request(conn)
45
+ buf = +''
46
+ buf << conn.readpartial(4096) until buf.include?("\r\n\r\n")
47
+ head, rest = buf.split("\r\n\r\n", 2)
48
+ request_line, *header_lines = head.split("\r\n")
49
+ headers = header_lines.each_with_object({}) do |line, acc|
50
+ name, value = line.split(': ', 2)
51
+ acc[name.downcase] = value
52
+ end
53
+ # Honour Expect: 100-continue so the client proceeds to send the body.
54
+ conn.write("HTTP/1.1 100 Continue\r\n\r\n") if headers['expect'].to_s =~ /100-continue/i
55
+ CapturedRequest.new(request_line, headers, read_body(conn, headers, rest))
56
+ end
57
+
58
+ def read_body(conn, headers, rest)
59
+ if headers['transfer-encoding'] == 'chunked'
60
+ dechunk(drain_chunked(conn, rest))
61
+ elsif (len = headers['content-length'])
62
+ read_exactly(conn, len.to_i, rest)
63
+ else
64
+ rest
65
+ end
66
+ end
67
+
68
+ def drain_chunked(conn, rest)
69
+ buf = rest.dup
70
+ buf << conn.readpartial(4096) until buf.include?("0\r\n\r\n")
71
+ buf
72
+ end
73
+
74
+ def read_exactly(conn, n, rest)
75
+ buf = rest.dup
76
+ buf << conn.readpartial(4096) while buf.bytesize < n
77
+ buf.byteslice(0, n)
78
+ end
79
+
80
+ def dechunk(buf)
81
+ io = StringIO.new(buf)
82
+ out = +''
83
+ while (size_line = io.gets("\r\n"))
84
+ size = size_line.strip.split(';', 2).first.to_i(16)
85
+ break if size.zero?
86
+ out << io.read(size).to_s
87
+ io.read(2) # trailing CRLF
88
+ end
89
+ out
90
+ end
91
+ end
92
+
93
+ RSpec.configure { |c| c.include RawRequestCapture }