Skip to content

Commit

Permalink
Merge pull request #129 from faye/ssl-verification
Browse files Browse the repository at this point in the history
Implement SSL certificate verification
  • Loading branch information
jcoglan committed Jul 31, 2020
2 parents f33fd69 + 8b76cd9 commit f41e67c
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 24 deletions.
32 changes: 32 additions & 0 deletions README.md
Expand Up @@ -198,6 +198,38 @@ is an optional hash containing any of these keys:
These are passed along to EventMachine and you can find
[more details here](http://rubydoc.info/gems/eventmachine/EventMachine%2FConnection%3Astart_tls)

### Secure sockets

Starting with version 0.11.0, `Faye::WebSocket::Client` will verify the server
certificate for `wss` connections. This is not the default behaviour for
EventMachine's TLS interface, and so our defaults for the `:tls` option are a
little different.

First, `:verify_peer` is enabled by default. Our implementation checks that the
chain of certificates sent by the server is trusted by your root certificates,
and that the final certificate's hostname matches the hostname in the request
URL.

By default, we use your system's root certificate store by invoking
`OpenSSL::X509::Store#set_default_paths`. If you want to use a different set of
root certificates, you can pass them via the `:root_cert_file` option, which
takes a path or an array of paths to the certificates you want to use.

```ruby
ws = Faye::WebSocket::Client.new('wss://example.com/', [], :tls => {
:root_cert_file => ['path/to/certificate.pem']
})
```

If you want to switch off certificate verification altogether, then set
`:verify_peer` to `false`.

```ruby
ws = Faye::WebSocket::Client.new('wss://example.com/', [], :tls => {
:verify_peer => false
})
```

## WebSocket API

Both the server- and client-side `WebSocket` objects support the following API:
Expand Down
2 changes: 2 additions & 0 deletions examples/client.rb
Expand Up @@ -6,9 +6,11 @@
EM.run {
url = ARGV[0]
proxy = ARGV[1]
ca = File.expand_path('../../spec/server.crt', __FILE__)

ws = Faye::WebSocket::Client.new(url, [],
:proxy => { :origin => proxy, :headers => { 'User-Agent' => 'Echo' } },
:tls => { :root_cert_file => ca },
:headers => { 'Origin' => 'http://faye.jcoglan.com' },
:extensions => [PermessageDeflate]
)
Expand Down
7 changes: 4 additions & 3 deletions lib/faye/websocket.rb
Expand Up @@ -17,9 +17,10 @@ module Faye
class WebSocket
root = File.expand_path('../websocket', __FILE__)

autoload :Adapter, root + '/adapter'
autoload :API, root + '/api'
autoload :Client, root + '/client'
autoload :Adapter, root + '/adapter'
autoload :API, root + '/api'
autoload :Client, root + '/client'
autoload :SslVerifier, root + '/ssl_verifier'

ADAPTERS = {
'goliath' => :Goliath,
Expand Down
58 changes: 39 additions & 19 deletions lib/faye/websocket/client.rb
Expand Up @@ -17,20 +17,18 @@ def initialize(url, protocols = nil, options = {})
super(options) { ::WebSocket::Driver.client(self, :max_length => options[:max_length], :protocols => protocols) }

proxy = options.fetch(:proxy, {})
endpoint = URI.parse(proxy[:origin] || @url)
port = endpoint.port || DEFAULT_PORTS[endpoint.scheme]
@secure = SECURE_PROTOCOLS.include?(endpoint.scheme)
@endpoint = URI.parse(proxy[:origin] || @url)
port = @endpoint.port || DEFAULT_PORTS[@endpoint.scheme]
@origin_tls = options.fetch(:tls, {})
@socket_tls = proxy[:origin] ? proxy.fetch(:tls, {}) : @origin_tls

configure_proxy(proxy)

EventMachine.connect(endpoint.host, port, Connection) do |conn|
EventMachine.connect(@endpoint.host, port, Connection) do |conn|
conn.parent = self
end
rescue => error
emit_error("Network error: #{ url }: #{ error.message }")
finalize_close
on_network_error(error)
end

private
Expand All @@ -46,38 +44,60 @@ def configure_proxy(proxy)
end

@proxy.on(:connect) do
uri = URI.parse(@url)
secure = SECURE_PROTOCOLS.include?(uri.scheme)
@proxy = nil

if secure
origin_tls = { :sni_hostname => uri.host }.merge(@origin_tls)
@stream.start_tls(origin_tls)
end

start_tls(URI.parse(@url), @origin_tls)
@driver.start
end
end

def start_tls(uri, options)
return unless SECURE_PROTOCOLS.include?(uri.scheme)

tls_options = { :sni_hostname => uri.host, :verify_peer => true }.merge(options)
@ssl_verifier = SslVerifier.new(uri.host, tls_options)
@stream.start_tls(tls_options)
end

def on_connect(stream)
@stream = stream

if @secure
socket_tls = { :sni_hostname => URI.parse(@url).host }.merge(@socket_tls)
@stream.start_tls(socket_tls)
end
start_tls(@endpoint, @socket_tls)

worker = @proxy || @driver
worker.start
end

def on_network_error(error)
emit_error("Network error: #{ @url }: #{ error.message }")
finalize_close
end

def ssl_verify_peer(cert)
@ssl_verifier.ssl_verify_peer(cert)
rescue => error
on_network_error(error)
end

def ssl_handshake_completed
@ssl_verifier.ssl_handshake_completed
rescue => error
on_network_error(error)
end

module Connection
attr_accessor :parent

def connection_completed
parent.__send__(:on_connect, self)
end

def ssl_verify_peer(cert)
parent.__send__(:ssl_verify_peer, cert)
end

def ssl_handshake_completed
parent.__send__(:ssl_handshake_completed)
end

def receive_data(data)
parent.__send__(:parse, data)
end
Expand Down
89 changes: 89 additions & 0 deletions lib/faye/websocket/ssl_verifier.rb
@@ -0,0 +1,89 @@
# This code is based on the implementation in Faraday:
#
# https://github.com/lostisland/faraday/blob/v1.0.1/lib/faraday/adapter/em_http_ssl_patch.rb
#
# Faraday is published under the MIT license as detailed here:
#
# https://github.com/lostisland/faraday/blob/v1.0.1/LICENSE.md
#
# Copyright (c) 2009-2019 Rick Olson, Zack Hobson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.

require 'openssl'

module Faye
class WebSocket

SSLError = Class.new(OpenSSL::SSL::SSLError)

class SslVerifier
def initialize(hostname, ssl_opts)
@hostname = hostname
@ssl_opts = ssl_opts
@cert_store = OpenSSL::X509::Store.new

if root = @ssl_opts[:root_cert_file]
[root].flatten.each { |ca_path| @cert_store.add_file(ca_path) }
else
@cert_store.set_default_paths
end
end

def ssl_verify_peer(cert_text)
return true unless should_verify?

certificate = parse_cert(cert_text)
return false unless certificate

unless @cert_store.verify(certificate)
raise SSLError, "Unable to verify the server certificate for '#{ @hostname }'"
end

store_cert(certificate)
@last_cert = certificate

true
end

def ssl_handshake_completed
return unless should_verify?

unless identity_verified?
raise SSLError, "Host '#{ @hostname }' does not match the server certificate"
end
end

private

def should_verify?
@ssl_opts[:verify_peer] != false
end

def parse_cert(cert_text)
OpenSSL::X509::Certificate.new(cert_text)
rescue OpenSSL::X509::CertificateError
nil
end

def store_cert(certificate)
@cert_store.add_cert(certificate)
rescue OpenSSL::X509::StoreError => error
raise error unless error.message == 'cert already in hash table'
end

def identity_verified?
@last_cert and OpenSSL::SSL.verify_certificate_identity(@last_cert, @hostname)
end
end

end
end
8 changes: 6 additions & 2 deletions spec/faye/websocket/client_spec.rb
Expand Up @@ -39,14 +39,14 @@ def open_socket(url, protocols, &callback)
end
end

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

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

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

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

let :tls_options do
{ :root_cert_file => File.expand_path('../../../server.crt', __FILE__) }
end

shared_examples_for "socket client" do
before do
@ever_opened = @message = nil
Expand Down

0 comments on commit f41e67c

Please sign in to comment.