diff --git a/lib/Duo/API.pm b/lib/Duo/API.pm index 8f2d1c7..d91c9e2 100644 --- a/lib/Duo/API.pm +++ b/lib/Duo/API.pm @@ -37,6 +37,11 @@ Duo::API objects have the following methods: Returns a handle to sign and send requests. These parameters are obtained when creating an API integration. +An optional C<$enable_ca_pinning> parameter (6th argument, default: true) +controls whether connections are validated against the pinned CA bundle. +When set to false, the TLS library validates using the OS trust store +instead. TLS verification is always enforced regardless of this setting. + =item json_api_call($method, $path, \%params) Make a request to an API endpoint with the given HTTPS method and @@ -111,6 +116,7 @@ use Digest::SHA qw(hmac_sha512_hex); use File::Basename qw( dirname ); use File::Spec::Functions qw(catfile); use JSON qw(decode_json encode_json); +use IO::Socket::SSL qw(SSL_VERIFY_PEER); use LWP::UserAgent; use MIME::Base64 qw(encode_base64); use POSIX qw(strftime); @@ -126,16 +132,18 @@ use constant RATE_LIMIT_HTTP_CODE => 429; use constant DEFAULT_CA_CERTS => catfile(dirname(abs_path((__FILE__))) , 'ca_certs.pem'); sub new { - my($proto, $ikey, $skey, $host, $paging_limit, $ca_certs) = @_; + my($proto, $ikey, $skey, $host, $paging_limit, $ca_certs, $enable_ca_pinning) = @_; my $class = ref($proto) || $proto; $paging_limit ||= 100; $ca_certs ||= DEFAULT_CA_CERTS; + $enable_ca_pinning = 1 unless defined($enable_ca_pinning); my $self = { - ikey => $ikey, - skey => $skey, - host => $host, - paging_limit => $paging_limit, - ca_certs => $ca_certs, + ikey => $ikey, + skey => $skey, + host => $host, + paging_limit => $paging_limit, + ca_certs => $ca_certs, + enable_ca_pinning => $enable_ca_pinning, }; bless($self, $class); return $self; @@ -222,11 +230,17 @@ sub api_call { sub make_request { my ($self, $req) = @_; + my %ssl_opts = ( + verify_hostname => 1, + SSL_verify_mode => SSL_VERIFY_PEER, + ); + + if ($self->{enable_ca_pinning}) { + $ssl_opts{SSL_ca_file} = $self->{ca_certs}; + } + my $ua = LWP::UserAgent->new( - ssl_opts => { - SSL_ca_file => $self->{ca_certs}, - verify_hostname => 1, - } + ssl_opts => \%ssl_opts, ); if ($ENV{'DEBUG'}) { diff --git a/t/api_calls.t b/t/api_calls.t index a7eca20..2c9e1ab 100644 --- a/t/api_calls.t +++ b/t/api_calls.t @@ -8,6 +8,7 @@ use URI; use URI::QueryParam; use Duo::API; +use IO::Socket::SSL qw(SSL_VERIFY_PEER); use LWP::UserAgent; use Time::HiRes; @@ -410,6 +411,52 @@ describe "A duo api client" => sub { ]); }; }; + + describe "CA pinning" => sub { + my $req = mock(); + my %captured_ssl_opts; + + $req->stubs( + as_string => "I am a request", + ); + + before each => sub { + %captured_ssl_opts = (); + + LWP::UserAgent->stubs(new => sub { + my ($class, %args) = @_; + %captured_ssl_opts = %{$args{ssl_opts}}; + my $ua = mock(); + $ua->stubs(request => sub { return $mock_response; }); + return $ua; + }); + + $mock_response->stubs(code => 200); + }; + + it "CA pinning is enabled by default" => sub { + my $client = Duo::API->new('ikey', 'skey', 'host'); + is($client->{enable_ca_pinning}, 1); + $client->make_request($req); + is($captured_ssl_opts{SSL_ca_file}, Duo::API::DEFAULT_CA_CERTS); + is($captured_ssl_opts{SSL_verify_mode}, SSL_VERIFY_PEER); + is($captured_ssl_opts{verify_hostname}, 1); + }; + + it "CA pinning can be disabled via constructor parameter" => sub { + my $client = Duo::API->new('ikey', 'skey', 'host', undef, undef, 0); + is($client->{enable_ca_pinning}, 0); + $client->make_request($req); + ok(!exists $captured_ssl_opts{SSL_ca_file}); + }; + + it "TLS verification is still enforced when CA pinning is disabled" => sub { + my $client = Duo::API->new('ikey', 'skey', 'host', undef, undef, 0); + $client->make_request($req); + is($captured_ssl_opts{SSL_verify_mode}, SSL_VERIFY_PEER); + is($captured_ssl_opts{verify_hostname}, 1); + }; + }; }; describe "test" => sub {