Skip to content

Commit 1d05346

Browse files
committed
Fix SSL test harness and re-enable skipped HTTPS tests
Closes #627.
1 parent 475c0fb commit 1d05346

6 files changed

Lines changed: 109 additions & 53 deletions

File tree

lib/http/connection.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ def readpartial(size = BUFFER_SIZE)
112112
chunk = @parser.read(size)
113113
return chunk if chunk
114114

115-
finished = (read_more(size) == :eof) || @parser.finished?
115+
eof = read_more(size) == :eof
116+
if eof && !@parser.finished? && body_framed?
117+
close
118+
raise ConnectionError, "response body ended prematurely"
119+
end
120+
121+
finished = eof || @parser.finished?
116122
chunk = @parser.read(size)
117123
finish_response if finished
118124

@@ -215,6 +221,11 @@ def init_state(options)
215221
@parser = Response::Parser.new
216222
end
217223

224+
def body_framed?
225+
@parser.headers.include?(Headers::TRANSFER_ENCODING) ||
226+
@parser.headers.include?(Headers::CONTENT_LENGTH)
227+
end
228+
218229
# Connect socket and set up proxy/TLS
219230
# @return [void]
220231
# @api private

test/http/client_test.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -431,10 +431,10 @@ def wrap_response(res)
431431
include HTTPHandlingTests
432432
end
433433

434-
# TODO: https://github.com/httprb/http/issues/627
435434
describe "working with SSL" do
436435
run_server(:dummy_ssl) { DummyServer.new(ssl: true) }
437436

437+
let(:options) { {} }
438438
let(:extra_options) { {} }
439439

440440
let(:client) do
@@ -443,8 +443,6 @@ def wrap_response(res)
443443

444444
let(:server) { dummy_ssl }
445445

446-
before { skip "TODO: https://github.com/httprb/http/issues/627" }
447-
448446
include HTTPHandlingTests
449447

450448
it "just works" do
@@ -566,8 +564,6 @@ def wrap_response(res)
566564

567565
context "with broken body (too early closed connection)" do
568566
it "raises HTTP::ConnectionError" do
569-
skip "TODO: broken chunked body handling"
570-
571567
response_data = [
572568
"HTTP/1.1 200 OK\r\n" \
573569
"Content-Type: application/json\r\n" \

test/http_test.rb

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,14 @@
105105
assert_match(/<!doctype html>/, response.to_s)
106106
end
107107

108-
# TODO: https://github.com/httprb/http/issues/627
109108
context "ssl" do
110109
it "responds with the endpoint's body" do
111-
skip "TODO: https://github.com/httprb/http/issues/627"
112110
response = ssl_client.via(proxy.addr, proxy.port).get dummy_ssl.endpoint
113111

114112
assert_match(/<!doctype html>/, response.to_s)
115113
end
116114

117115
it "ignores credentials" do
118-
skip "TODO: https://github.com/httprb/http/issues/627"
119116
response = ssl_client.via(proxy.addr, proxy.port, "username", "password").get dummy_ssl.endpoint
120117

121118
assert_match(/<!doctype html>/, response.to_s)
@@ -150,24 +147,20 @@
150147
assert_equal 407, response.status.to_i
151148
end
152149

153-
# TODO: https://github.com/httprb/http/issues/627
154150
context "ssl" do
155151
it "responds with the endpoint's body" do
156-
skip "TODO: https://github.com/httprb/http/issues/627"
157152
response = ssl_client.via(proxy.addr, proxy.port, "username", "password").get dummy_ssl.endpoint
158153

159154
assert_match(/<!doctype html>/, response.to_s)
160155
end
161156

162157
it "responds with 407 when wrong credentials given" do
163-
skip "TODO: https://github.com/httprb/http/issues/627"
164158
response = ssl_client.via(proxy.addr, proxy.port, "user", "pass").get dummy_ssl.endpoint
165159

166160
assert_equal 407, response.status.to_i
167161
end
168162

169163
it "responds with 407 if no credentials given" do
170-
skip "TODO: https://github.com/httprb/http/issues/627"
171164
response = ssl_client.via(proxy.addr, proxy.port).get dummy_ssl.endpoint
172165

173166
assert_equal 407, response.status.to_i

test/support/dummy_server.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def initialize(options = {})
1515
@memo = {}
1616
@servlet = Servlet.new(self, @memo)
1717
@running = false
18+
ssl_context if @ssl
1819
end
1920

2021
def addr

test/support/proxy_server.rb

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
require "support/servers/runner"
88

99
class ProxyServer
10+
Target = Struct.new(:host, :port, :path, :query, keyword_init: true)
11+
1012
def initialize
1113
@tcp_server = TCPServer.new("127.0.0.1", 0)
1214
@port = @tcp_server.addr[1]
@@ -40,16 +42,20 @@ def shutdown
4042
private
4143

4244
def handle_request(client)
43-
method, uri, version, headers, body = read_proxy_request(client)
44-
return unless method
45+
method, target, version, headers, body = read_proxy_request(client)
46+
return unless method && target
4547

4648
if (response = authenticate(headers))
4749
client.write(response)
4850
return
4951
end
5052

51-
forward_and_respond(client, method, uri, body, version)
52-
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
53+
if method == "CONNECT"
54+
tunnel_connection(client, target)
55+
else
56+
forward_and_respond(client, method, target, body, version)
57+
end
58+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, URI::InvalidURIError
5359
# Connection closed
5460
ensure
5561
client.close rescue nil # rubocop:disable Style/RescueModifier
@@ -59,11 +65,27 @@ def read_proxy_request(client)
5965
line = client.gets
6066
return unless line
6167

62-
method, url, version = line.strip.split(" ", 3)
68+
method, target, version = line.strip.split(" ", 3)
6369
headers = read_headers(client)
6470
body = headers["Content-Length"] ? client.read(headers["Content-Length"].to_i) : nil
6571

66-
[method, URI.parse(url), version, headers, body]
72+
[method, parse_target(method, target), version, headers, body]
73+
end
74+
75+
def parse_target(method, target)
76+
return parse_connect_target(target) if method == "CONNECT"
77+
78+
uri = URI.parse(target)
79+
Target.new(host: uri.host, port: uri.port, path: uri.path, query: uri.query)
80+
end
81+
82+
def parse_connect_target(target)
83+
host, port = target.split(":", 2)
84+
return unless host && port
85+
86+
Target.new(host: host, port: Integer(port))
87+
rescue ArgumentError
88+
nil
6789
end
6890

6991
def read_headers(client)
@@ -81,24 +103,24 @@ def authenticate(_headers)
81103
nil
82104
end
83105

84-
def forward_and_respond(client, method, uri, body, version)
85-
target = send_to_target(method, uri, body, version)
86-
relay_response(client, target)
106+
def forward_and_respond(client, method, target, body, version)
107+
target_socket = send_to_target(method, target, body, version)
108+
relay_response(client, target_socket)
87109
ensure
88-
target&.close rescue nil # rubocop:disable Style/RescueModifier
110+
target_socket&.close rescue nil # rubocop:disable Style/RescueModifier
89111
end
90112

91-
def send_to_target(method, uri, body, version)
92-
target = TCPSocket.new(uri.host, uri.port)
93-
path = uri.path.empty? ? "/" : uri.path
94-
path = "#{path}?#{uri.query}" if uri.query
95-
96-
target.write("#{method} #{path} #{version}\r\n")
97-
target.write("Host: #{uri.host}:#{uri.port}\r\n")
98-
target.write("Content-Length: #{body.bytesize}\r\n") if body
99-
target.write("\r\n")
100-
target.write(body) if body
101-
target
113+
def send_to_target(method, target, body, version)
114+
socket = TCPSocket.new(target.host, target.port)
115+
path = target.path.to_s.empty? ? "/" : target.path
116+
path = "#{path}?#{target.query}" if target.query
117+
118+
socket.write("#{method} #{path} #{version}\r\n")
119+
socket.write("Host: #{target.host}:#{target.port}\r\n")
120+
socket.write("Content-Length: #{body.bytesize}\r\n") if body
121+
socket.write("\r\n")
122+
socket.write(body) if body
123+
socket
102124
end
103125

104126
def relay_response(client, target)
@@ -113,6 +135,30 @@ def relay_response(client, target)
113135
# Target connection error
114136
end
115137

138+
def tunnel_connection(client, target)
139+
target_socket = TCPSocket.new(target.host, target.port)
140+
141+
client.write("HTTP/1.1 200 Connection established\r\n\r\n")
142+
relay_tunnel(client, target_socket)
143+
ensure
144+
target_socket&.close rescue nil # rubocop:disable Style/RescueModifier
145+
end
146+
147+
def relay_tunnel(client, target)
148+
[
149+
Thread.new { copy_stream(client, target) },
150+
Thread.new { copy_stream(target, client) }
151+
].each(&:join)
152+
end
153+
154+
def copy_stream(source, destination)
155+
loop do
156+
destination.write(source.readpartial(1024))
157+
end
158+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
159+
nil
160+
end
161+
116162
def read_response_headers(target)
117163
headers = +""
118164
content_length = nil

test/support/ssl_helper.rb

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,19 @@
66

77
module SSLHelper
88
CERTS_PATH = Pathname.new File.expand_path("../../tmp/certs", __dir__)
9+
CA_EXTENSIONS = {
10+
"basicConstraints" => { "ca" => true, "critical" => true },
11+
"keyUsage" => { "usage" => %w[critical keyCertSign cRLSign] },
12+
"extendedKeyUsage" => { "usage" => [] }
13+
}.freeze
14+
SERVER_EXTENSIONS = {
15+
"basicConstraints" => { "ca" => false },
16+
"keyUsage" => { "usage" => %w[critical digitalSignature keyEncipherment] },
17+
"extendedKeyUsage" => { "usage" => ["serverAuth"] },
18+
"subjectAltName" => { "ips" => ["127.0.0.1"] }
19+
}.freeze
920

1021
class RootCertificate < ::CertificateAuthority::Certificate
11-
EXTENSIONS = { "keyUsage" => { "usage" => %w[critical keyCertSign] } }.freeze
12-
1322
def initialize
1423
super
1524

@@ -19,7 +28,7 @@ def initialize
1928

2029
self.signing_entity = true
2130

22-
sign!("extensions" => EXTENSIONS)
31+
sign!("extensions" => CA_EXTENSIONS)
2332
end
2433

2534
def file
@@ -35,17 +44,17 @@ def file
3544
end
3645

3746
class ChildCertificate < ::CertificateAuthority::Certificate
38-
def initialize(parent)
47+
def initialize(parent, common_name:, serial_number:, extensions:)
3948
super()
4049

41-
subject.common_name = "127.0.0.1"
42-
serial_number.number = 1
50+
subject.common_name = common_name
51+
self.serial_number.number = serial_number
4352

4453
key_material.generate_key
4554

4655
self.parent = parent
4756

48-
sign!
57+
sign!("extensions" => extensions)
4958
end
5059

5160
def cert
@@ -61,7 +70,7 @@ class << self
6170
def server_context
6271
context = OpenSSL::SSL::SSLContext.new
6372

64-
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
73+
context.verify_mode = OpenSSL::SSL::VERIFY_NONE
6574
context.key = server_cert.key
6675
context.cert = server_cert.cert
6776
context.ca_file = ca.file
@@ -70,31 +79,31 @@ def server_context
7079
end
7180

7281
def client_context
82+
server_cert
7383
context = OpenSSL::SSL::SSLContext.new
7484

7585
context.options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options]
7686
context.verify_mode = OpenSSL::SSL::VERIFY_PEER
77-
context.key = client_cert.key
78-
context.cert = client_cert.cert
79-
context.ca_file = ca.file
87+
context.verify_hostname = true if context.respond_to?(:verify_hostname=)
88+
context.ca_file = ca.file
8089

8190
context
8291
end
8392

8493
def client_params
94+
server_cert
8595
{
86-
key: client_cert.key,
87-
cert: client_cert.cert,
8896
ca_file: ca.file
8997
}
9098
end
9199

92-
%w[server client].each do |side|
93-
class_eval <<-RUBY, __FILE__, __LINE__ + 1
94-
def #{side}_cert
95-
@#{side}_cert ||= ChildCertificate.new ca
96-
end
97-
RUBY
100+
def server_cert
101+
@server_cert ||= ChildCertificate.new(
102+
ca,
103+
common_name: "127.0.0.1",
104+
serial_number: 2,
105+
extensions: SERVER_EXTENSIONS
106+
)
98107
end
99108

100109
def ca

0 commit comments

Comments
 (0)