-
Notifications
You must be signed in to change notification settings - Fork 124
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2766 from cyberark/cnjr-230-websocket-proxy
Finish HTTP proxy support in Kubernetes authenticator
- Loading branch information
Showing
10 changed files
with
531 additions
and
131 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
121 changes: 121 additions & 0 deletions
121
app/domain/authentication/authn_k8s/proxied_tcp_socket.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
# frozen_string_literal: true | ||
|
||
require 'delegate' | ||
require 'socket' | ||
|
||
module Authentication | ||
module AuthnK8s | ||
# A proxy socket first establishes a TCP connection through a configured | ||
# proxy server | ||
# | ||
# :reek:TooManyInstanceVariables | ||
# :reek:InstanceVariableAssumption for @proxy_socket | ||
class ProxiedTcpSocket < SimpleDelegator | ||
attr_reader :tcp_socket | ||
|
||
def initialize( | ||
proxy_uri:, | ||
destination_host:, | ||
destination_port:, | ||
timeout: 60, # seconds | ||
|
||
# Injected dependencies | ||
logger: Rails.logger | ||
) | ||
@proxy_uri = proxy_uri | ||
@destination_host = destination_host | ||
@destination_port = destination_port | ||
@timeout = timeout | ||
@logger = logger | ||
|
||
@tcp_socket = connect_proxy_socket | ||
|
||
# Connect to the proxy | ||
super(@tcp_socket) | ||
end | ||
|
||
protected | ||
|
||
def connect_proxy_socket | ||
# We log the proxy host and port specifically because the full URI may | ||
# contain authorization fields. | ||
@logger.debug( | ||
"Connecting to '#{@destination_host}:#{@destination_port}' " \ | ||
"through proxy server: '#{@proxy_uri.host}:#{@proxy_uri.port}'" | ||
) | ||
|
||
@proxy_socket = TCPSocket.new( | ||
@proxy_uri.host, | ||
@proxy_uri.port, | ||
connect_timeout: @timeout | ||
) | ||
|
||
# Send proxy connection handshake | ||
@proxy_socket.write(proxy_connect_string) | ||
|
||
# This will block until the response is received. It raises an | ||
# exception if the proxy response is not received or is invalid. | ||
wait_for_proxy_response | ||
|
||
@proxy_socket | ||
end | ||
|
||
# :reek:DuplicateMethodCall for @proxy_uri methods | ||
def wait_for_proxy_response | ||
# Set the deadline time for the socket response. | ||
deadline = Time.now + @timeout | ||
|
||
# Loop until the expected string is received or the deadline is reached. | ||
response = '' | ||
while !response.include?("\r\n\r\n") && Time.now < deadline | ||
# Calculate the remaining time until the deadline. | ||
remaining_time = deadline - Time.now | ||
|
||
# Wait until the socket is ready to be read, or until the | ||
# deadline is reached. | ||
ready = IO.select([@proxy_socket], nil, nil, remaining_time) | ||
|
||
unless ready | ||
# The deadline has been reached without receiving the | ||
# expected string | ||
raise "Timed out waiting for the proxy " \ | ||
"('#{@proxy_uri.host}:#{@proxy_uri.port}') to respond. " \ | ||
"Received: '#{response.strip}'" | ||
end | ||
|
||
# Read from the socket and append to the response string | ||
response += @proxy_socket.read(1) | ||
end | ||
|
||
# Verify we received a valid connection response | ||
return if response.downcase.include?('200 connection established') | ||
|
||
# If we didn't receive the expected response, raise an error | ||
raise "Proxy ('#{@proxy_uri.host}:#{@proxy_uri.port}') returned an " \ | ||
"invalid response: '#{response.strip}'" | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
# frozen_string_literal: true | ||
|
||
require 'delegate' | ||
require 'openssl' | ||
|
||
module Authentication | ||
module AuthnK8s | ||
# A secure socket wraps an existing TCP socket and establishes a secure | ||
# TLS context with it. | ||
# | ||
# :reek:TooManyInstanceVariables | ||
class SecureTcpSocket < SimpleDelegator | ||
def self.default_cert_store | ||
OpenSSL::X509::Store.new.tap(&:set_default_paths) | ||
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 | ||
) | ||
@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 |
Oops, something went wrong.