From 964d06e7baea98eb1ddbf1906c43995a2292d6bf Mon Sep 17 00:00:00 2001 From: Micah Lee Date: Tue, 4 Apr 2023 12:30:44 -0400 Subject: [PATCH] Add HTTP proxy support to k8s websocket --- .../authn_k8s/proxied_tcp_socket.rb | 72 ++++++ .../authn_k8s/secure_tcp_socket.rb | 72 ++++++ .../authn_k8s/web_socket_client.rb | 228 +++++++++++------- .../authn_k8s/proxied_tcp_socket_spec.rb | 95 ++++++++ ...spec_spec.rb => web_socket_client_spec.rb} | 21 +- 5 files changed, 397 insertions(+), 91 deletions(-) create mode 100644 app/domain/authentication/authn_k8s/proxied_tcp_socket.rb create mode 100644 app/domain/authentication/authn_k8s/secure_tcp_socket.rb create mode 100644 spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb rename spec/app/domain/authentication/authn_k8s/{web_socket_client_spec_spec.rb => web_socket_client_spec.rb} (98%) diff --git a/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb b/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb new file mode 100644 index 0000000000..7ece0dd913 --- /dev/null +++ b/app/domain/authentication/authn_k8s/proxied_tcp_socket.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'delegate' + +module Authentication + module AuthnK8s + # A proxy socket first establishes a TCP connection through a configured + # proxy server + class ProxiedTcpSocket < SimpleDelegator + + def initialize( + proxy_uri:, + destination_host:, + destination_port:, + + # Injected dependencies + logger: ::Rails.logger + ) + @proxy_uri = proxy_uri + @destination_host = destination_host + @destination_port = destination_port + + # Connect to the proxy + super(connect_proxy_socket) + end + + protected + + def connect_proxy_socket + proxy_socket = TCPSocket.new(@proxy_uri.host, @proxy_uri.port) + + # Send proxy connection handshake + proxy_socket.write(proxy_connect_string) + + # Wait for connect response + response = '' + until response.include?("\r\n\r\n") + response += proxy_socket.read(1) + end + + # Verify we received a valid connection response + if !response.downcase.include?('200 connection established') + raise "Failed to connect through proxy " \ + "('#{@proxy_uri.host}:#{@proxy_uri.port}'). " \ + "Response: #{response.strip}" + end + end + + # For spec details, see: + # https://httpwg.org/specs/rfc9110.html#CONNECT + def proxy_connect_string + connect_string = \ + "CONNECT #{@destination_host}:#{@destination_port} HTTP/1.1\r\n" \ + "Host: #{@destination_host}\r\n" + + if proxy_authorization + connect_string += \ + "Proxy-Authorization: Basic #{proxy_authorization}\r\n" + end + + connect_string += "\r\n" + end + + # :reek:DuplicateMethodCall because of accessing #user and #password twice + def proxy_authorization + return unless @proxy_uri.user && @proxy_uri.password + + Base64.strict_encode64("#{@proxy_uri.user}:#{@proxy_uri.password}") + end + end + end +end diff --git a/app/domain/authentication/authn_k8s/secure_tcp_socket.rb b/app/domain/authentication/authn_k8s/secure_tcp_socket.rb new file mode 100644 index 0000000000..3495537c89 --- /dev/null +++ b/app/domain/authentication/authn_k8s/secure_tcp_socket.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'delegate' + +module Authentication + module AuthnK8s + # A proxy socket first establishes a TCP connection through a configured + # proxy server + class SecureTcpSocket < SimpleDelegator + def self.default_cert_store + OpenSSL::X509::Store.new.tap do |cert_store| + cert_store.set_default_paths + end + end + + def initialize( + socket:, + + # Optional keyword arguments to configure the TLS behavior + hostname: nil, + headers: nil, + cert_store: SecureTcpSocket.default_cert_store, + verify_mode: OpenSSL::SSL::VERIFY_PEER, + + # Injected dependencies + logger: ::Rails.logger + ) + @socket = socket + + @hostname = hostname + @headers = headers + @cert_store = cert_store + @verify_mode = verify_mode + + super(secure_socket) + end + + protected + + def secure_socket + # Wrap the provided TCP socket with an SSLSocket + OpenSSL::SSL::SSLSocket.new(@socket, openssl_context).tap do |socket| + # support SNI, see https://www.cloudflare.com/en-gb/learning/ssl/what-is-sni/ + # don't set SNI hostname for IP address per RFC 6066, section 3. + socket.hostname = @hostname unless ip_address? + + # Establish secure connection + socket.connect + socket.post_connection_check(@hostname) + end + end + + def ip_address? + @hostname.match?(Resolv::IPv4::Regex) || + @hostname.match?(Resolv::IPv6::Regex) + end + + def openssl_context + OpenSSL::SSL::SSLContext.new.tap do |ctx| + # Set the certificate store + ctx.cert_store = @cert_store + + # Verify the TLS peer by default unless a verify mode is specified + ctx.verify_mode = @verify_mode + + # Avoid openssl warning on hostname verification for IP address + ctx.verify_hostname = false if ip_address? + end + end + end + end +end diff --git a/app/domain/authentication/authn_k8s/web_socket_client.rb b/app/domain/authentication/authn_k8s/web_socket_client.rb index 8ccd1e413b..119b6f3894 100644 --- a/app/domain/authentication/authn_k8s/web_socket_client.rb +++ b/app/domain/authentication/authn_k8s/web_socket_client.rb @@ -1,115 +1,84 @@ +# frozen_string_literal: true + ## This code is based on github.com/shokai/websocket-client-simple (MIT License) require 'event_emitter' require 'websocket' require 'resolv' require 'openssl' +require 'uri' + +require 'rails' # Utility class for processing WebSocket messages. module Authentication module AuthnK8s class WebSocketClient include EventEmitter - attr_reader :url, :handshake - def self.connect(url, options = {}) - client = WebSocketClient.new + attr_reader :handshake + + def self.connect(url, **options) + client = WebSocketClient.new(url, **options) yield(client) if block_given? - client.connect(url, options) + client.connect client end - # connect provides options :hostname, :headers, :ssl_version, :cert_store, :verify_mode - def connect(url, options = {}) - return if @socket + def initialize( + url, - @url = url - uri = URI.parse(url) - is_secure_connection = %w[https wss].include?(uri.scheme) - @socket = TCPSocket.new(uri.host, - uri.port || (is_secure_connection ? 443 : 80)) - if is_secure_connection - ctx = OpenSSL::SSL::SSLContext.new - ssl_version = options[:ssl_version] - ctx.ssl_version = ssl_version if ssl_version - ctx.verify_mode = options[:verify_mode] || OpenSSL::SSL::VERIFY_PEER # use VERIFY_PEER for verification - cert_store = options[:cert_store] - - unless cert_store - cert_store = OpenSSL::X509::Store.new - cert_store.set_default_paths - end - ctx.cert_store = cert_store - - use_sni = false - ssl_host_address = options[:hostname] || uri.host # use the param :hostname or default to the host of the url argument - - case uri.host - when Resolv::IPv4::Regex, Resolv::IPv6::Regex - # don't set SNI, as IP addresses in SNI is not valid - # per RFC 6066, section 3. - - # Avoid openssl warning - ctx.verify_hostname = false - else - use_sni = true - end + # Optional keyword arguments to configure the secure socket behavior + hostname: nil, + headers: nil, + cert_store: SecureTcpSocket.default_cert_store, + verify_mode: OpenSSL::SSL::VERIFY_PEER, - @socket = ::OpenSSL::SSL::SSLSocket.new(@socket, ctx) + # Injected dependencies + logger: ::Rails.logger + ) + # Parse the given url to ensure it's valid + @uri = URI.parse(url) - # support SNI, see https://www.cloudflare.com/en-gb/learning/ssl/what-is-sni/ - @socket.hostname = ssl_host_address if use_sni + # Use the provided port or default to the standard ports + @port = @uri.port || (secure? ? 443 : 80) - @socket.connect + @hostname = hostname + @headers = headers + @cert_store = cert_store + @verify_mode = verify_mode - # mandatory hostname verification applicable to both hostnames and IP addresses - @socket.post_connection_check(ssl_host_address) - end - @handshake = ::WebSocket::Handshake::Client.new(url: url, headers: options[:headers]) - @handshaked = false - @pipe_broken = false - frame = ::WebSocket::Frame::Incoming::Client.new - @closed = false - once(:__close) do |err| - close - emit(:close, err) - end + @logger = logger + end - @thread = Thread.new do - until @closed - begin - unless recv_data = @socket.getc - sleep(1) - next - end - if @handshaked - frame << recv_data - while msg = frame.next - emit(:message, msg) - end - else - @handshake << recv_data + def connect + # Do nothing if already connected + return if @socket - # completed handshake - if @handshake.finished? - @handshaked = true - emit(:open) - end - end - rescue => e - emit(:error, e) - end - end - end + # Establish initial connection to server + open_socket + # If the connection uses TLS, establish the secure context + secure_socket if secure? + + # Begin websocket IO loop in a separate thread + begin_event_loop + + # Send initial websocket handshake + @handshake ||= WebSocket::Handshake::Client.new( + url: @uri.to_s, + headers: @headers + ) @socket.write(@handshake.to_s) end def send(data, opt = { type: :text }) - return if !@handshaked || @closed + return if !@handshake_complete || @closed type = opt[:type] - frame = ::WebSocket::Frame::Outgoing::Client.new(data: data, type: type, version: @handshake.version) + frame = ::WebSocket::Frame::Outgoing::Client.new( + data: data, + type: type, version: @handshake.version) begin @socket.write(frame.to_s) rescue Errno::EPIPE => e @@ -124,10 +93,13 @@ def close unless @pipe_broken send(nil, type: :close) end + @closed = true @socket&.close @socket = nil + emit(:__close) + Thread.kill(@thread) if @thread end @@ -135,6 +107,100 @@ def open? @handshake.finished? && !@closed end + protected + + def open_socket + @socket = if proxy_uri + ProxiedTcpSocket.new( + proxy_uri: @proxy_uri, + destination_host: @uri.host, + destination_port: @port + ) + else + TCPSocket.new(@uri.host, @port) + end + end + + # Wrap the given tcp_socket in an SSL socket to secure the connection + def secure_socket + @socket = SecureTcpSocket.new( + socket: @socket, + hostname: @hostname || @uri.host, + cert_store: @cert_store, + verify_mode: @verify_mode + ) + end + + # This returns the proxy url relevant to the connection from the + # environment. If the server connection uses TLS, then use the + # https_proxy value, otherwise use the http_proxy value. + def proxy_uri + @proxy_uri ||= begin + proxy_url = if secure? + ENV['https_proxy'] || ENV['HTTPS_PROXY'] + else + ENV['http_proxy'] || ENV['HTTP_PROXY'] + end + + URI.parse(proxy_url) + rescue URI::InvalidURIError + nil + end + end + + def secure? + %w[https wss].include?(@uri.scheme) + end + + def begin_event_loop + @handshake_complete = false + @pipe_broken = false + @closed = false + + # Set up event handler with the websocket is closed + once(:__close) do |err| + close + emit(:close, err) + end + + @thread = Thread.new do + until @closed + begin + unless recv_data = @socket.getc + sleep(1) + next + end + + if @handshake_complete + process_incoming_data(recv_data) + else + process_handshake_data(recv_data) + end + rescue => e + emit(:error, e) + end + end + end + end + + def process_incoming_data(recv_data) + frame << recv_data + while msg = @frame.next + emit(:message, msg) + end + end + + def process_handshake_data(recv_data) + @handshake << recv_data + if @handshake.finished? + @handshaked = true + emit(:open) + end + end + + def frame + @frame ||= WebSocket::Frame::Incoming::Client.new + end end end end diff --git a/spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb b/spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb new file mode 100644 index 0000000000..b5960ae6b9 --- /dev/null +++ b/spec/app/domain/authentication/authn_k8s/proxied_tcp_socket_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'openssl' +require 'stringio' + +require 'domain/authentication/authn_k8s/proxied_tcp_socket' + +describe Authentication::AuthnK8s::ProxiedTcpSocket do + describe '#new' do + let(:proxy_uri) { URI.parse("http://proxy.local:8080") } + let(:destination_host) { 'destination.example.com' } + let(:destination_port) { 443 } + + let(:proxy_response) do + "200 connection established\r\n\r\n" + end + + let(:proxy_data) { StringIO.new } + + let(:tcp_socket_double) do + instance_double(TCPSocket).tap do |tcp_socket_double| + allow(tcp_socket_double).to receive(:write) do |value| + proxy_data << value + end + + allow(tcp_socket_double).to receive(:read).and_return(proxy_response) + end + end + + subject do + described_class.new( + proxy_uri: proxy_uri, + destination_host: destination_host, + destination_port: destination_port + ) + end + + before do + allow(TCPSocket).to receive(:new).and_return(tcp_socket_double) + end + + it "establishes a TCP connection through the configured proxy" do + expect(TCPSocket).to receive(:new).with(proxy_uri.host, proxy_uri.port) + subject + end + + it "sends the connect messages" do + subject + expect(proxy_data.string).to include( + "CONNECT destination.example.com:443 HTTP/1.1\r\n" \ + "Host: destination.example.com\r\n\r\n" + ) + end + + context "when the proxy connection fails" do + let(:proxy_response) do + "500 internal server error\r\n\r\n" + end + + it "raises an exception" do + expect { subject }.to raise_error( + RuntimeError, + "Failed to connect through proxy ('proxy.local:8080'). " \ + "Response: 500 internal server error" + ) + end + end + + context "when proxy authorization is set" do + let(:proxy_uri) { URI.parse("http://user:pass@proxy.local:8080") } + + it "include the proxy authorization header" do + subject + + auth_value = Base64.strict_encode64( + "#{proxy_uri.user}:#{proxy_uri.password}" + ) + expect(proxy_data.string).to include( + "Proxy-Authorization: Basic #{auth_value}" + ) + end + end + + context "when only proxy username is set" do + let(:proxy_uri) { URI.parse("http://user@proxy.local:8080") } + + it "doesn't include the proxy authorization header" do + subject + + expect(proxy_data.string).not_to include("Proxy-Authorization:") + end + end + end +end \ No newline at end of file diff --git a/spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb b/spec/app/domain/authentication/authn_k8s/web_socket_client_spec.rb similarity index 98% rename from spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb rename to spec/app/domain/authentication/authn_k8s/web_socket_client_spec.rb index 39e28fd9a1..0dc1700d7f 100644 --- a/spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb +++ b/spec/app/domain/authentication/authn_k8s/web_socket_client_spec.rb @@ -3,6 +3,7 @@ # Run this file by calling: # bundle exec rspec spec/app/domain/authentication/authn_k8s/web_socket_client_spec_spec.rb --format documentation +require 'spec_helper' require 'openssl' require 'domain/authentication/authn_k8s/web_socket_client' @@ -21,20 +22,20 @@ end describe 'Authentication::AuthnK8s::WebSocketClient' do - context 'server running' do + context 'server running' do context 'without TLS' do before(:example) do @test_server = WebSocketTestServer.new @client = nil end - + after(:example) do @test_server.close @client && @client.close @test_server = nil @client = nil end - + it 'has good handshake with no options' do @test_server.add_websocket @test_server.run @@ -43,7 +44,7 @@ expect(@test_server.good_handshake?).to be_truthy expect(@client.open?).to be_truthy end - + it 'has good handshake with options' do @test_server.add_websocket @test_server.run @@ -53,7 +54,7 @@ expect(@test_server.good_handshake?).to be_truthy expect(@client.open?).to be_truthy end - + it 'is not open for communication without a good handshake' do @test_server.add_bad_websocket @test_server.run @@ -80,13 +81,13 @@ it 'fails cert verification without options' do @test_server.add_websocket @test_server.run - expect { + expect { @client = Authentication::AuthnK8s::WebSocketClient.connect("wss://localhost:#{@test_server.port}") }.to raise_error(OpenSSL::SSL::SSLError, nil) { |error| - expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") + expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") } end - + it 'passes all TLS verifications with good options' do @test_server.add_websocket @test_server.run @@ -152,10 +153,10 @@ it 'fails cert verification without options' do @test_server.add_websocket @test_server.run - expect { + expect { @client = Authentication::AuthnK8s::WebSocketClient.connect("wss://localhost:#{@test_server.port}") }.to raise_error(OpenSSL::SSL::SSLError, nil) { |error| - expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") + expect(error.message).to eq("SSL_connect returned=1 errno=0 state=error: certificate verify failed (unable to get local issuer certificate)") } end