diff --git a/lib/duo_api/api_client.rb b/lib/duo_api/api_client.rb index 2e8b4b5..ed738a6 100644 --- a/lib/duo_api/api_client.rb +++ b/lib/duo_api/api_client.rb @@ -13,7 +13,7 @@ # class DuoApi attr_accessor :ca_file - attr_reader :default_params + attr_reader :default_params, :ca_pinning_disabled VERSION = Gem.loaded_specs['duo_api'] ? Gem.loaded_specs['duo_api'].version : '0.0.0' @@ -22,7 +22,7 @@ class DuoApi INITIAL_BACKOFF_WAIT_SECS = 1 BACKOFF_FACTOR = 2 - def initialize(ikey, skey, host, proxy = nil, ca_file: nil, default_params: {}) + def initialize(ikey, skey, host, proxy = nil, ca_file: nil, disable_ca_pinning: false, default_params: {}) @ikey = ikey @skey = skey @host = host @@ -38,8 +38,15 @@ def initialize(ikey, skey, host, proxy = nil, ca_file: nil, default_params: {}) proxy_uri.password ] end - @ca_file = ca_file || - File.join(File.dirname(__FILE__), '..', '..', 'ca_certs.pem') + + raise ArgumentError, 'Cannot both disable CA pinning and provide a custom CA file' if disable_ca_pinning && ca_file + + @ca_pinning_disabled = disable_ca_pinning + @ca_file = if disable_ca_pinning + nil + else + ca_file || File.join(File.dirname(__FILE__), '..', '..', 'ca_certs.pem') + end @default_params = default_params.transform_keys(&:to_sym) end @@ -80,11 +87,9 @@ def request(method, path, params = {}, additional_headers = nil) end # Start the HTTP session - Net::HTTP.start( - uri.host, uri.port, *@proxy, - use_ssl: true, ca_file: @ca_file, - verify_mode: OpenSSL::SSL::VERIFY_PEER - ) do |http| + http_opts = { use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER } + http_opts[:ca_file] = @ca_file if @ca_file + Net::HTTP.start(uri.host, uri.port, *@proxy, **http_opts) do |http| wait_secs = INITIAL_BACKOFF_WAIT_SECS loop do resp = http.request(request) diff --git a/test/test_disable_ca_pinning.rb b/test/test_disable_ca_pinning.rb new file mode 100644 index 0000000..5131044 --- /dev/null +++ b/test/test_disable_ca_pinning.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'common' + +class TestDisableCaPinning < Test::Unit::TestCase + def test_default_has_ca_pinning_enabled + client = DuoApi.new(IKEY, SKEY, HOST) + assert_equal(false, client.ca_pinning_disabled) + assert_not_nil(client.ca_file) + assert(File.exist?(client.ca_file)) + end + + def test_disable_ca_pinning_removes_ca_file + client = DuoApi.new(IKEY, SKEY, HOST, nil, disable_ca_pinning: true) + assert_equal(true, client.ca_pinning_disabled) + assert_nil(client.ca_file) + end + + def test_disable_ca_pinning_and_custom_ca_file_raises + assert_raise(ArgumentError) do + DuoApi.new(IKEY, SKEY, HOST, nil, ca_file: '/some/cert.pem', disable_ca_pinning: true) + end + end + + def test_disable_ca_pinning_and_custom_ca_file_error_message + error = assert_raise(ArgumentError) do + DuoApi.new(IKEY, SKEY, HOST, nil, ca_file: '/some/cert.pem', disable_ca_pinning: true) + end + assert_equal('Cannot both disable CA pinning and provide a custom CA file', error.message) + end + + def test_custom_ca_file_without_disable_works + client = DuoApi.new(IKEY, SKEY, HOST, nil, ca_file: '/some/cert.pem') + assert_equal(false, client.ca_pinning_disabled) + assert_equal('/some/cert.pem', client.ca_file) + end +end + +class TestDisableCaPinningHTTP < BaseTestCase + def setup + @client_pinned = DuoApi.new(IKEY, SKEY, HOST) + @client_unpinned = DuoApi.new(IKEY, SKEY, HOST, nil, disable_ca_pinning: true) + @mock_http = mock + @ok_resp = Net::HTTPSuccess.new('200') + end + + def test_pinned_client_passes_ca_file_to_http + ca_file = @client_pinned.ca_file + Net::HTTP.expects(:start).with do |_host, _port, **opts| + opts[:use_ssl] == true && + opts[:verify_mode] == OpenSSL::SSL::VERIFY_PEER && + opts[:ca_file] == ca_file + end.yields(@mock_http) + @mock_http.expects(:request).returns(@ok_resp) + @client_pinned.request('GET', '/foo/bar') + end + + def test_unpinned_client_does_not_pass_ca_file_to_http + Net::HTTP.expects(:start).with do |_host, _port, **opts| + opts[:use_ssl] == true && + opts[:verify_mode] == OpenSSL::SSL::VERIFY_PEER && + !opts.key?(:ca_file) + end.yields(@mock_http) + @mock_http.expects(:request).returns(@ok_resp) + @client_unpinned.request('GET', '/foo/bar') + end +end