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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/ci.yml +79 -0
- data/.travis.yml +10 -7
- data/CHANGELOG.md +32 -1
- data/Gemfile +15 -1
- data/README.md +80 -30
- data/ext/patron/extconf.rb +7 -4
- data/ext/patron/session_ext.c +360 -155
- data/lib/patron/error.rb +4 -0
- data/lib/patron/request.rb +8 -6
- data/lib/patron/response.rb +5 -0
- data/lib/patron/session.rb +19 -4
- data/lib/patron/version.rb +1 -1
- data/patron.gemspec +8 -15
- data/script/generate_client_certs +65 -0
- data/spec/request_spec.rb +12 -0
- data/spec/session_spec.rb +287 -67
- data/spec/session_ssl_spec.rb +163 -27
- data/spec/spec_helper.rb +16 -7
- data/spec/support/certs/client.p12 +0 -0
- data/spec/support/certs/client_ca.pem +20 -0
- data/spec/support/certs/client_cert.pem +20 -0
- data/spec/support/certs/client_key.pem +30 -0
- data/spec/support/certs/rogue_client.p12 +0 -0
- data/spec/support/config.ru +18 -3
- data/spec/support/fork.rb +70 -0
- data/spec/support/raw_request_capture.rb +93 -0
- data/spec/support/test_server.rb +6 -3
- data/spec/support/valgrind.supp +2 -0
- metadata +22 -125
data/spec/session_ssl_spec.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
data/spec/support/config.ru
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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 }
|