Skip to content

Commit f41e67c

Browse files
authored
Merge pull request #129 from faye/ssl-verification
Implement SSL certificate verification
2 parents f33fd69 + 8b76cd9 commit f41e67c

File tree

6 files changed

+172
-24
lines changed

6 files changed

+172
-24
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,38 @@ is an optional hash containing any of these keys:
198198
These are passed along to EventMachine and you can find
199199
[more details here](http://rubydoc.info/gems/eventmachine/EventMachine%2FConnection%3Astart_tls)
200200

201+
### Secure sockets
202+
203+
Starting with version 0.11.0, `Faye::WebSocket::Client` will verify the server
204+
certificate for `wss` connections. This is not the default behaviour for
205+
EventMachine's TLS interface, and so our defaults for the `:tls` option are a
206+
little different.
207+
208+
First, `:verify_peer` is enabled by default. Our implementation checks that the
209+
chain of certificates sent by the server is trusted by your root certificates,
210+
and that the final certificate's hostname matches the hostname in the request
211+
URL.
212+
213+
By default, we use your system's root certificate store by invoking
214+
`OpenSSL::X509::Store#set_default_paths`. If you want to use a different set of
215+
root certificates, you can pass them via the `:root_cert_file` option, which
216+
takes a path or an array of paths to the certificates you want to use.
217+
218+
```ruby
219+
ws = Faye::WebSocket::Client.new('wss://example.com/', [], :tls => {
220+
:root_cert_file => ['path/to/certificate.pem']
221+
})
222+
```
223+
224+
If you want to switch off certificate verification altogether, then set
225+
`:verify_peer` to `false`.
226+
227+
```ruby
228+
ws = Faye::WebSocket::Client.new('wss://example.com/', [], :tls => {
229+
:verify_peer => false
230+
})
231+
```
232+
201233
## WebSocket API
202234

203235
Both the server- and client-side `WebSocket` objects support the following API:

examples/client.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
EM.run {
77
url = ARGV[0]
88
proxy = ARGV[1]
9+
ca = File.expand_path('../../spec/server.crt', __FILE__)
910

1011
ws = Faye::WebSocket::Client.new(url, [],
1112
:proxy => { :origin => proxy, :headers => { 'User-Agent' => 'Echo' } },
13+
:tls => { :root_cert_file => ca },
1214
:headers => { 'Origin' => 'http://faye.jcoglan.com' },
1315
:extensions => [PermessageDeflate]
1416
)

lib/faye/websocket.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ module Faye
1717
class WebSocket
1818
root = File.expand_path('../websocket', __FILE__)
1919

20-
autoload :Adapter, root + '/adapter'
21-
autoload :API, root + '/api'
22-
autoload :Client, root + '/client'
20+
autoload :Adapter, root + '/adapter'
21+
autoload :API, root + '/api'
22+
autoload :Client, root + '/client'
23+
autoload :SslVerifier, root + '/ssl_verifier'
2324

2425
ADAPTERS = {
2526
'goliath' => :Goliath,

lib/faye/websocket/client.rb

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,18 @@ def initialize(url, protocols = nil, options = {})
1717
super(options) { ::WebSocket::Driver.client(self, :max_length => options[:max_length], :protocols => protocols) }
1818

1919
proxy = options.fetch(:proxy, {})
20-
endpoint = URI.parse(proxy[:origin] || @url)
21-
port = endpoint.port || DEFAULT_PORTS[endpoint.scheme]
22-
@secure = SECURE_PROTOCOLS.include?(endpoint.scheme)
20+
@endpoint = URI.parse(proxy[:origin] || @url)
21+
port = @endpoint.port || DEFAULT_PORTS[@endpoint.scheme]
2322
@origin_tls = options.fetch(:tls, {})
2423
@socket_tls = proxy[:origin] ? proxy.fetch(:tls, {}) : @origin_tls
2524

2625
configure_proxy(proxy)
2726

28-
EventMachine.connect(endpoint.host, port, Connection) do |conn|
27+
EventMachine.connect(@endpoint.host, port, Connection) do |conn|
2928
conn.parent = self
3029
end
3130
rescue => error
32-
emit_error("Network error: #{ url }: #{ error.message }")
33-
finalize_close
31+
on_network_error(error)
3432
end
3533

3634
private
@@ -46,38 +44,60 @@ def configure_proxy(proxy)
4644
end
4745

4846
@proxy.on(:connect) do
49-
uri = URI.parse(@url)
50-
secure = SECURE_PROTOCOLS.include?(uri.scheme)
5147
@proxy = nil
52-
53-
if secure
54-
origin_tls = { :sni_hostname => uri.host }.merge(@origin_tls)
55-
@stream.start_tls(origin_tls)
56-
end
57-
48+
start_tls(URI.parse(@url), @origin_tls)
5849
@driver.start
5950
end
6051
end
6152

53+
def start_tls(uri, options)
54+
return unless SECURE_PROTOCOLS.include?(uri.scheme)
55+
56+
tls_options = { :sni_hostname => uri.host, :verify_peer => true }.merge(options)
57+
@ssl_verifier = SslVerifier.new(uri.host, tls_options)
58+
@stream.start_tls(tls_options)
59+
end
60+
6261
def on_connect(stream)
6362
@stream = stream
64-
65-
if @secure
66-
socket_tls = { :sni_hostname => URI.parse(@url).host }.merge(@socket_tls)
67-
@stream.start_tls(socket_tls)
68-
end
63+
start_tls(@endpoint, @socket_tls)
6964

7065
worker = @proxy || @driver
7166
worker.start
7267
end
7368

69+
def on_network_error(error)
70+
emit_error("Network error: #{ @url }: #{ error.message }")
71+
finalize_close
72+
end
73+
74+
def ssl_verify_peer(cert)
75+
@ssl_verifier.ssl_verify_peer(cert)
76+
rescue => error
77+
on_network_error(error)
78+
end
79+
80+
def ssl_handshake_completed
81+
@ssl_verifier.ssl_handshake_completed
82+
rescue => error
83+
on_network_error(error)
84+
end
85+
7486
module Connection
7587
attr_accessor :parent
7688

7789
def connection_completed
7890
parent.__send__(:on_connect, self)
7991
end
8092

93+
def ssl_verify_peer(cert)
94+
parent.__send__(:ssl_verify_peer, cert)
95+
end
96+
97+
def ssl_handshake_completed
98+
parent.__send__(:ssl_handshake_completed)
99+
end
100+
81101
def receive_data(data)
82102
parent.__send__(:parse, data)
83103
end

lib/faye/websocket/ssl_verifier.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# This code is based on the implementation in Faraday:
2+
#
3+
# https://github.com/lostisland/faraday/blob/v1.0.1/lib/faraday/adapter/em_http_ssl_patch.rb
4+
#
5+
# Faraday is published under the MIT license as detailed here:
6+
#
7+
# https://github.com/lostisland/faraday/blob/v1.0.1/LICENSE.md
8+
#
9+
# Copyright (c) 2009-2019 Rick Olson, Zack Hobson
10+
#
11+
# Permission is hereby granted, free of charge, to any person obtaining a copy
12+
# of this software and associated documentation files (the "Software"), to deal
13+
# in the Software without restriction, including without limitation the rights
14+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15+
# copies of the Software, and to permit persons to whom the Software is
16+
# furnished to do so, subject to the following conditions:
17+
#
18+
# The above copyright notice and this permission notice shall be included in
19+
# all copies or substantial portions of the Software.
20+
21+
require 'openssl'
22+
23+
module Faye
24+
class WebSocket
25+
26+
SSLError = Class.new(OpenSSL::SSL::SSLError)
27+
28+
class SslVerifier
29+
def initialize(hostname, ssl_opts)
30+
@hostname = hostname
31+
@ssl_opts = ssl_opts
32+
@cert_store = OpenSSL::X509::Store.new
33+
34+
if root = @ssl_opts[:root_cert_file]
35+
[root].flatten.each { |ca_path| @cert_store.add_file(ca_path) }
36+
else
37+
@cert_store.set_default_paths
38+
end
39+
end
40+
41+
def ssl_verify_peer(cert_text)
42+
return true unless should_verify?
43+
44+
certificate = parse_cert(cert_text)
45+
return false unless certificate
46+
47+
unless @cert_store.verify(certificate)
48+
raise SSLError, "Unable to verify the server certificate for '#{ @hostname }'"
49+
end
50+
51+
store_cert(certificate)
52+
@last_cert = certificate
53+
54+
true
55+
end
56+
57+
def ssl_handshake_completed
58+
return unless should_verify?
59+
60+
unless identity_verified?
61+
raise SSLError, "Host '#{ @hostname }' does not match the server certificate"
62+
end
63+
end
64+
65+
private
66+
67+
def should_verify?
68+
@ssl_opts[:verify_peer] != false
69+
end
70+
71+
def parse_cert(cert_text)
72+
OpenSSL::X509::Certificate.new(cert_text)
73+
rescue OpenSSL::X509::CertificateError
74+
nil
75+
end
76+
77+
def store_cert(certificate)
78+
@cert_store.add_cert(certificate)
79+
rescue OpenSSL::X509::StoreError => error
80+
raise error unless error.message == 'cert already in hash table'
81+
end
82+
83+
def identity_verified?
84+
@last_cert and OpenSSL::SSL.verify_certificate_identity(@last_cert, @hostname)
85+
end
86+
end
87+
88+
end
89+
end

spec/faye/websocket/client_spec.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ def open_socket(url, protocols, &callback)
3939
end
4040
end
4141

42-
@ws = Faye::WebSocket::Client.new(url, protocols, :proxy => { :origin => proxy_url })
42+
@ws = Faye::WebSocket::Client.new(url, protocols, :proxy => { :origin => proxy_url }, :tls => tls_options)
4343

4444
@ws.on(:open) { |e| resume.call(true) }
4545
@ws.onclose = lambda { |e| resume.call(false) }
4646
end
4747

4848
def open_socket_and_close_it_fast(url, protocols, &callback)
49-
@ws = Faye::WebSocket::Client.new(url, protocols)
49+
@ws = Faye::WebSocket::Client.new(url, protocols, :tls => tls_options)
5050

5151
@ws.on(:open) { |e| @open = @ever_opened = true }
5252
@ws.onclose = lambda { |e| @open = false }
@@ -130,6 +130,10 @@ def wait(seconds, &callback)
130130
let(:wrong_url) { "ws://#{ localhost }:9999/" }
131131
let(:secure_url) { "wss://#{ localhost }:#{ port }/" }
132132

133+
let :tls_options do
134+
{ :root_cert_file => File.expand_path('../../../server.crt', __FILE__) }
135+
end
136+
133137
shared_examples_for "socket client" do
134138
before do
135139
@ever_opened = @message = nil

0 commit comments

Comments
 (0)