# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'net/http' require 'resolv' require 'contrast/components/logger' require 'contrast/utils/object_share' require 'socket' module Contrast module Utils # This module creates a Net::HTTP client base to be used by different services # All HTTP clients reporting to Telemetry or TS should inherit from this class NetHttpBase # rubocop:disable Metrics/ClassLength include Contrast::Components::Logger::InstanceMethods class << self # Last recorded error # @return [StandardError] def last_error @_last_error end # @param [StandardError] def last_error= error @_last_error = error end end # @return [String] attr_reader :client_name # This method initializes the Net::HTTP client we'll need. it will validate # the connection and make the first request. If connection is valid and response # is available then the open connection is returned. # # @param service_name [String] Name of service used in logging messages # @param url [String] # @param use_proxy [Boolean] flag used to indicate proxy connections [default = false] # @param use_custom_cert [Boolean] flag used to indicate whether the client is to use # self signed certificates provided by config [default = false] # @return [Net::HTTP, nil] Return open connection or nil def initialize_connection service_name, url, use_proxy: false, use_custom_cert: false Contrast::Utils::NetHttpBase.last_error = nil @client_name = service_name return unless (addr = retrieve_address(url)) return unless (net_http_client = configure_new_client(addr, use_proxy, use_custom_cert)) return unless client_started?(net_http_client) logger.debug("Starting #{ client_name } connection test") return unless connection_verified?(net_http_client, url) logger.debug('Client verified', service: client_name, url: url) net_http_client rescue StandardError => e Contrast::Utils::NetHttpBase.last_error = e return if client_name == Contrast::Agent::Telemetry::Client::SERVICE_NAME logger.error('Connection failed', e, service: client_name, url: url) nil end # Validates connection with assigned domain. # If connection is running, SSL certificate of the endpoint is valid, Ip address is resolvable # and response is received without peer's reset or refuse of connection, then validation returns true. # # @param client [Net::HTTP] # @param url [String] # @return [Boolean] true | false def connection_verified? client, url return @_connection_verified unless @_connection_verified.nil? return false if client.nil? ipaddr = get_ipaddr(client) response = client.request(Net::HTTP::Get.new(url)) verify_cert = client.address.to_s.include?('localhost') || OpenSSL::SSL.verify_certificate_identity(client.peer_cert, client.address) resolved = resolved?(client.address, ipaddr) @_connection_verified = if resolved && response && verify_cert true else false end rescue OpenSSL::SSL::SSLError, Resolv::ResolvError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ESHUTDOWN, Errno::EHOSTDOWN, Errno::EHOSTUNREACH, Errno::EISCONN, Errno::ECONNABORTED, Errno::ENETRESET, Errno::ENETUNREACH => e Contrast::Utils::NetHttpBase.last_error = e unless client_name == Contrast::Agent::Telemetry::Client::SERVICE_NAME logger.error("#{ client_name } connection failed", e.message) end @_connection_verified = false end private # Starts connection and return started status. # # @param client [Net::HTTP] client instance. # @return [Boolean] status indicates whether connection has started. def client_started? client return false unless client client.start client.started? end # @param url # @return [URI::Generic, nil] def retrieve_address url return unless (addr = URI(url)) return if addr.host.nil? || addr.port.nil? return if addr.scheme != 'https' && !addr.host.to_s.include?('localhost') addr end # Assigns proxy and custom certificates if enabled, and initializes new client. # # @param addr [URI::Generic, nil] # @param use_proxy [Boolean] flag used to indicate proxy connections [default = false] # @param use_custom_cert [Boolean] flag used to indicate whether the client is to use # @return client [Net::HTTP, nil] initialized client. def configure_new_client addr, use_proxy, use_custom_cert # the proxy is enabled only if there is provided url even if the enable is set to true proxy_addr = URI(Contrast::API.proxy_url) if proxy_enabled? net_http_client = initialize_client(addr, proxy_addr, use_proxy, use_custom_cert) return if net_http_client.nil? net_http_client end # Resolves the address of the assigned domain to array of corresponding IPs (if more than one) # and runs a matcher to see if current connection IP is in the list. # This is called within #verify_connection, if called on it's own there will be no # error handling. # # @param address [String] Human friendly address of assigned domain # @param ipaddr [String] Machine friendly IP address of the assigned domain # @return[Boolean] true if both addresses are resolved | false if one of the addresses # is non-resolvable def resolved? address, ipaddr return @_resolved unless @_resolved.nil? @_resolved = if (addresses = Resolv.getaddresses(address)) addresses.any? { |addr| addr.include?(ipaddr) } else false end end # if the configuration for the use of self-signed certificates is enabled # and required for this client then assign the files and keys to the client # # @param client [Net::HTTP] def assign_cert client if Contrast::API.certification_ca_file client.ca_file = OpenSSL::X509::Certificate.new(File.read(Contrast::API.certification_ca_file)).to_s elsif Contrast::API.certification_cert_file client.cert = OpenSSL::X509::Certificate.new(File.read(Contrast::API.certification_cert_file)).to_s elsif Contrast::API.certification_key_file client.key = OpenSSL::PKey::RSA.new(File.read(Contrast::API.certification_key_file)).to_s end rescue Errno::ENOENT => e Contrast::Utils::NetHttpBase.last_error = e unless client_name == Contrast::Agent::Telemetry::Client::SERVICE_NAME logger.error('Custom certificates failed', e.message) end end # sets default setting for client validation of certificates and # timeout options # # @param addr [URI::Generic] uri of assigned domain # @param proxy_addr [URI::Generic | nil] uri of proxy # @param use_proxy [Boolean] flag used to indicate proxy connections [default = false] # @param use_custom_cert [Boolean] flag used to indicate whether the client is to use # self signed certificates provided by config [default = false] # @return [Net::HTTP] def initialize_client addr, proxy_addr, use_proxy, use_custom_cert initialize_client = if proxy_enabled? && use_proxy Net::HTTP.new(addr.host, nil, proxy_addr&.host, proxy_addr&.port) else Net::HTTP.new(addr.host, addr.port) end return initialize_client if addr.host.to_s.include?('localhost') # TODO: RUBY-99999 allow http w/ localhost assign_cert(initialize_client) if use_custom_cert && Contrast::API.certification_enable initialize_client.use_ssl = true initialize_client.verify_mode = OpenSSL::SSL::VERIFY_PEER initialize_client.verify_depth = 5 # open connection timeout in ms # if connection reaches timeout this will produce Net::OpenTimeout error initialize_client.open_timeout = 15 # if we can't read the response or a chunk within time this will cause a # Net::ReadTimeout error when the request is made initialize_client.read_timeout = 15 initialize_client end # Check if proxy is enabled # # @return @_proxy_enabled [Boolean] True if proxy is enabled and url is present else false def proxy_enabled? return @_proxy_enabled unless @_proxy_enabled.nil? @_proxy_enabled = Contrast::API.proxy_enable && !Contrast::API.proxy_url.nil? end # Retrieve the IP address from the client. # # @param client [Net::HTTP] # @return [String] def get_ipaddr client socket = TCPSocket.open(client.address, client.port) ipaddr = socket.peeraddr[3] socket.close ipaddr end end end end