From 6257b2c473a274dbcd01b64729f9cf7700a055f6 Mon Sep 17 00:00:00 2001 From: James Coglan Date: Sun, 19 Jul 2020 22:13:33 +0100 Subject: [PATCH 1/2] Implement SSL certificate verification, based on Faraday's implementation: https://github.com/lostisland/faraday/blob/v1.0.1/lib/faraday/adapter/em_http_ssl_patch.rb --- examples/client.rb | 2 + lib/faye/websocket.rb | 7 ++- lib/faye/websocket/client.rb | 58 ++++++++++++------- lib/faye/websocket/ssl_verifier.rb | 89 ++++++++++++++++++++++++++++++ spec/faye/websocket/client_spec.rb | 8 ++- 5 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 lib/faye/websocket/ssl_verifier.rb diff --git a/examples/client.rb b/examples/client.rb index 89472c6..925c80d 100644 --- a/examples/client.rb +++ b/examples/client.rb @@ -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] ) diff --git a/lib/faye/websocket.rb b/lib/faye/websocket.rb index c648c01..0b8210c 100644 --- a/lib/faye/websocket.rb +++ b/lib/faye/websocket.rb @@ -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, diff --git a/lib/faye/websocket/client.rb b/lib/faye/websocket/client.rb index 07ce674..d5828e5 100644 --- a/lib/faye/websocket/client.rb +++ b/lib/faye/websocket/client.rb @@ -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 @@ -46,31 +44,45 @@ 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 @@ -78,6 +90,14 @@ 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 diff --git a/lib/faye/websocket/ssl_verifier.rb b/lib/faye/websocket/ssl_verifier.rb new file mode 100644 index 0000000..b6de380 --- /dev/null +++ b/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 diff --git a/spec/faye/websocket/client_spec.rb b/spec/faye/websocket/client_spec.rb index 1b09b57..6a69224 100644 --- a/spec/faye/websocket/client_spec.rb +++ b/spec/faye/websocket/client_spec.rb @@ -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 } @@ -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 From 8b76cd904ae47a8eb641f45f8bf572a1ad94bb6e Mon Sep 17 00:00:00 2001 From: James Coglan Date: Fri, 31 Jul 2020 12:58:04 +0100 Subject: [PATCH 2/2] Document the new `:tls` options for certificate verification --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 2765865..650442f 100644 --- a/README.md +++ b/README.md @@ -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: