Skip to content

Commit

Permalink
Introduce authentication middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
ekohl committed May 6, 2021
1 parent 6c0ee87 commit 5a63e4e
Show file tree
Hide file tree
Showing 25 changed files with 233 additions and 82 deletions.
50 changes: 2 additions & 48 deletions lib/proxy/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,17 @@ def log_halt(code = nil, exception_or_msg = nil, custom_msg = nil)
halt code, message
end

# read the HTTPS client certificate from the environment and extract its CN
def https_cert_cn
certificate_raw = request.env['SSL_CLIENT_CERT'].to_s
log_halt 403, 'could not read client cert from environment' if certificate_raw.empty?

begin
certificate = OpenSSL::X509::Certificate.new certificate_raw
if certificate.subject && certificate.subject.to_s =~ /CN=([^\s\/,]+)/i
$1
else
log_halt 403, 'could not read CN from the client certificate'
end
rescue OpenSSL::X509::CertificateError => e
log_halt 403, "could not parse the client certificate\n\n#{e.message}"
end
end

# parses the body as json and returns a hash of the body
# returns empty hash if there is a json parse error, the body is empty or is not a hash
# request.env["CONTENT_TYPE"] must contain application/json in order for the json to be parsed
def parse_json_body
def parse_json_body(request)
json_data = {}
# if the user has explicitly set the content_type then there must be something worth decoding
# we use a regex because it might contain something else like: application/json;charset=utf-8
# by default the content type will probably be set to "application/x-www-form-urlencoded" unless the
# user changed it. If the user doesn't specify the content type we just ignore the body since a form
# will be parsed into the request.params object for us by sinatra
if request.env["CONTENT_TYPE"] =~ /application\/json/
if request.media_type == 'application/json'
begin
body_parameters = request.body.read
json_data = JSON.parse(body_parameters)
Expand All @@ -77,33 +60,4 @@ def dns_resolv(*args)
def resolv(*args)
::Proxy::LoggingResolv.new(Resolv.new(*args))
end

# reverse lookup an IP address while verifying it via forward resolv
def remote_fqdn(forward_verify = true)
ip = request.env['REMOTE_ADDR']
log_halt 403, 'could not get remote address from environment' if ip.empty?

begin
dns = resolv
fqdn = dns.getname(ip)
rescue Resolv::ResolvError => e
log_halt 403, "unable to resolve hostname for ip address #{ip}\n\n#{e.message}"
end

if forward_verify
begin
forward = dns.getaddresses(fqdn)
rescue Resolv::ResolvError => e
log_halt 403, "could not forward verify the remote hostname - #{fqdn} (#{ip})\n\n#{e.message}"
end

if forward.include?(ip)
fqdn
else
log_halt 403, "untrusted client has no matching forward DNS lookup - #{fqdn} (#{ip})"
end
else
fqdn
end
end
end
110 changes: 110 additions & 0 deletions lib/proxy/middleware/authorization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
module Proxy
module Middleware
class Authorization
include ::Proxy::Log

def initialize(app)
@app = app
end

def call(env)
if https?(env)
certificate_raw = https_client_cert_raw(env)
return unauthorized if certificate_raw.empty?

if trusted_hosts?
begin
certificate = OpenSSL::X509::Certificate.new(certificate_raw)
rescue OpenSSL::X509::CertificateError => e
logger.warn("Could not parse the client certificate: #{e.message}")
return unauthorized
end

fqdn = get_cn_from_certificate(certificate)
unless fqdn
logger.warn('Could not read CN from the client certificate')
return unauthorized
end

return denied(fqdn) unless trusted_host?(fqdn)
end
elsif trusted_hosts?
return denied(fqdn) unless trusted_host?(remote_fqdn)
end

@app.call(env)
end

private

def settings
Proxy::SETTINGS
end

def unauthorized
[401, {}, ['Unauthorized']]
end

def denied(fqdn)
path = request.path_info # TODO
logger.warn("Untrusted client #{fqdn} attempted to access #{path}. Check :trusted_hosts: in settings.yml")
[403, {}, ['Denied']]
end

def https?(env)
['yes', 'on', 1].include?(env['HTTPS'].to_s)
end

def https_client_cert_raw(env)
env['SSL_CLIENT_CERT'].to_s
end

def trusted_hosts?
settings.trusted_hosts
end

def trusted_host?(fqdn)
logger.debug "verifying remote client #{fqdn} against trusted_hosts #{trusted_hosts}"
trusted_hosts.include?(fqdn.downcase)
end

# reverse lookup an IP address while verifying it via forward resolv
def remote_fqdn
ip = env['REMOTE_ADDR']
log_halt 403, 'could not get remote address from environment' if ip.empty?

begin
dns = resolv
fqdn = dns.getname(ip)
rescue Resolv::ResolvError => e
log_halt 403, "unable to resolve hostname for ip address #{ip}\n\n#{e.message}"
end

if settings.forward_verify
begin
forward = dns.getaddresses(fqdn)
rescue Resolv::ResolvError => e
log_halt 403, "could not forward verify the remote hostname - #{fqdn} (#{ip})\n\n#{e.message}"
end

if forward.include?(ip)
fqdn
else
log_halt 403, "untrusted client has no matching forward DNS lookup - #{fqdn} (#{ip})"
end
else
fqdn
end
end

def get_cn_from_certificate(certificate)
return unless certificate&.subject

cn = certificate.subject.to_a.find { |oid| oid == 'CN' }
return unless cn

cn[2]
end
end
end
end
93 changes: 83 additions & 10 deletions lib/sinatra/authorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,19 @@ def do_authorize_with_trusted_hosts
# HTTP: test the reverse DNS entry of the remote IP
trusted_hosts = Proxy::SETTINGS.trusted_hosts
if trusted_hosts
logger.debug "verifying remote client #{request.env['REMOTE_ADDR']} against trusted_hosts #{trusted_hosts}"
fqdn = (https?(request) ? https_cert_cn(request) : remote_fqdn(Proxy::SETTINGS.forward_verify)).downcase

if ['yes', 'on', 1].include? request.env['HTTPS'].to_s
fqdn = https_cert_cn
else
fqdn = remote_fqdn(Proxy::SETTINGS.forward_verify)
end
fqdn = fqdn.downcase
logger.debug "verifying remote client #{fqdn} against trusted_hosts #{trusted_hosts}"

unless Proxy::SETTINGS.trusted_hosts.include?(fqdn)
unless trusted_hosts.include?(fqdn)
log_halt 403, "Untrusted client #{fqdn} attempted to access #{request.path_info}. Check :trusted_hosts: in settings.yml"
end
end
end

def do_authorize_with_ssl_client
if ['yes', 'on', '1'].include? request.env['HTTPS'].to_s
if request.env['SSL_CLIENT_CERT'].to_s.empty?
if https?(request)
if https_client_cert_raw(request).empty?
log_halt 403, "No client SSL certificate supplied"
end
else
Expand All @@ -60,6 +55,84 @@ def do_authorize_any
do_authorize_with_trusted_hosts
do_authorize_with_ssl_client
end

private

def https?(request)
['yes', 'on', 1].include?(request.env['HTTPS'].to_s)
end

def https_client_cert_raw(request)
request.env['SSL_CLIENT_CERT'].to_s
end

# read the HTTPS client certificate from the environment and extract its CN
def https_cert_cn(request)
log_halt 403, 'No HTTPS environment' unless https?(request)

certificate_raw = https_client_cert_raw(request)
certificate = parse_openssl_cert(certificate_raw)
log_halt 403, 'could not read client cert from environment' unless certificate

cn = get_cn_from_certificate(certificate)
log_halt 403, 'could not read CN from the client certificate' unless certificate

cn
rescue OpenSSL::X509::CertificateError => e
log_halt 403, "could not parse the client certificate\n\n#{e.message}"
end

# reverse lookup an IP address while verifying it via forward resolv
def remote_fqdn(forward_verify = true)
ip = request.env['REMOTE_ADDR']
log_halt 403, 'could not get remote address from environment' if ip.empty?

begin
dns = resolv
fqdn = dns.getname(ip)
rescue Resolv::ResolvError => e
log_halt 403, "unable to resolve hostname for ip address #{ip}\n\n#{e.message}"
end

if forward_verify
begin
forward = dns.getaddresses(fqdn)
rescue Resolv::ResolvError => e
log_halt 403, "could not forward verify the remote hostname - #{fqdn} (#{ip})\n\n#{e.message}"
end

if forward.include?(ip)
fqdn
else
log_halt 403, "untrusted client has no matching forward DNS lookup - #{fqdn} (#{ip})"
end
else
fqdn
end
end

def parse_openssl_cert(certificate_raw)
return if certificate_raw.nil? || certificate_raw.empty?

OpenSSL::X509::Certificate.new(certificate_raw)
end

def get_cn_from_certificate(certificate)
return unless certificate&.subject

cn = certificate.subject.to_a.find { |oid| oid == 'CN' }
return unless cn

cn[2]
end
end

def authorize!
include Helpers

before do
do_authorize_with_any
end
end

def authorize_with_trusted_hosts
Expand Down
1 change: 1 addition & 0 deletions lib/smart_proxy_main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
require 'proxy/http_download'
require 'proxy/helpers'
require 'proxy/memory_store'
require 'proxy/middleware/authorization'
require 'proxy/plugin_validators'
require 'proxy/pluggable'
require 'proxy/plugins'
Expand Down
4 changes: 1 addition & 3 deletions modules/bmc/bmc_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
module Proxy::BMC
class Api < ::Sinatra::Base
helpers ::Proxy::Helpers
authorize_with_trusted_hosts
authorize_with_ssl_client
# All GET requests will only read ipmi data, no changes
# All PUT requests will update information on the bmc device

Expand Down Expand Up @@ -449,7 +447,7 @@ def bmc_setup
# also if the user decides to do http://127.0.0.1/bmc/192.168.1.6/test?bmc_provider=freeipmi as well as pass in
# a json encode body with the parameters, all of these items will be merged together
def body_parameters
@body_parameters ||= parse_json_body.merge(params)
@body_parameters ||= parse_json_body(request).merge(params)
end

def auth
Expand Down
2 changes: 0 additions & 2 deletions modules/dhcp/dhcp_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ class Proxy::DhcpApi < ::Sinatra::Base
extend Proxy::DHCP::DependencyInjection

helpers ::Proxy::Helpers
authorize_with_trusted_hosts
authorize_with_ssl_client
use Rack::MethodOverride

inject_attr :dhcp_provider, :server
Expand Down
1 change: 1 addition & 0 deletions modules/dhcp/http_config.ru
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'dhcp/dhcp_api'

map "/dhcp" do
use Proxy::Middleware::Authorization
run Proxy::DhcpApi
end
2 changes: 0 additions & 2 deletions modules/dns/dns_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ class Api < ::Sinatra::Base
inject_attr :dns_provider, :server

helpers ::Proxy::Helpers
authorize_with_trusted_hosts
authorize_with_ssl_client

post "/?" do
fqdn = params[:fqdn]
Expand Down
1 change: 1 addition & 0 deletions modules/dns/http_config.ru
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'dns/dns_api'

map "/dns" do
use Proxy::Middleware::Authorization
run Proxy::Dns::Api
end
2 changes: 0 additions & 2 deletions modules/facts/facts_api.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
class Proxy::FactsApi < Sinatra::Base
helpers ::Proxy::Helpers
authorize_with_trusted_hosts
authorize_with_ssl_client

get "/?" do
content_type :json
Expand Down
1 change: 1 addition & 0 deletions modules/facts/http_config.ru
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'facts/facts_api'

map "/facts" do
use Proxy::Middleware::Authorization
run Proxy::FactsApi
end
1 change: 1 addition & 0 deletions modules/logs/http_config.ru
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'logs/logs_api'

map "/logs" do
use Proxy::Middleware::Authorization
run Proxy::LogsApi
end
2 changes: 0 additions & 2 deletions modules/logs/logs_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@

class Proxy::LogsApi < Sinatra::Base
helpers ::Proxy::Helpers
authorize_with_trusted_hosts
authorize_with_ssl_client

get "/" do
content_type :json
Expand Down
1 change: 1 addition & 0 deletions modules/puppet_proxy/http_config.ru
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require 'puppet_proxy/puppet_api'

map "/puppet" do
use Proxy::Middleware::Authorization
run Proxy::Puppet::Api
end
Loading

0 comments on commit 5a63e4e

Please sign in to comment.