From f40aafaf927a9cf7f94534380d873bb3c7d93736 Mon Sep 17 00:00:00 2001 From: Dmytro Hissa Date: Thu, 16 Jul 2026 16:08:50 +0200 Subject: [PATCH] Add CA bundle version and pinning status to the user agent string --- lib/duo_api/api_client.rb | 12 ++++++++- test/test_disable_ca_pinning.rb | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/lib/duo_api/api_client.rb b/lib/duo_api/api_client.rb index ed738a6..daddd20 100644 --- a/lib/duo_api/api_client.rb +++ b/lib/duo_api/api_client.rb @@ -17,6 +17,9 @@ class DuoApi VERSION = Gem.loaded_specs['duo_api'] ? Gem.loaded_specs['duo_api'].version : '0.0.0' + # Version of the bundled CA certificate collection + CA_BUNDLE_VERSION = '1.0' + # Constants for handling rate limit backoff MAX_BACKOFF_WAIT_SECS = 32 INITIAL_BACKOFF_WAIT_SECS = 1 @@ -78,7 +81,7 @@ def request(method, path, params = {}, additional_headers = nil) request = Net::HTTP.const_get(method.capitalize).new(uri.to_s) request.basic_auth(@ikey, signed) request['Date'] = current_date - request['User-Agent'] = "duo_api_ruby/#{VERSION}" + request['User-Agent'] = user_agent # Set Content-Type and request body for JSON requests if params_go_in_body @@ -106,6 +109,13 @@ def request(method, path, params = {}, additional_headers = nil) private + # Build the User-Agent string, including the CA bundle version and the + # current CA pinning state. + def user_agent + ca_pinning_state = @ca_pinning_disabled ? 'disable' : 'enable' + "duo_api_ruby/#{VERSION} ca_bundle/#{CA_BUNDLE_VERSION} (ca_pinning=#{ca_pinning_state})" + end + # Encode a key-value pair for a URL def encode_key_val(key, val) key = ERB::Util.url_encode(key.to_s) diff --git a/test/test_disable_ca_pinning.rb b/test/test_disable_ca_pinning.rb index 5131044..23bee85 100644 --- a/test/test_disable_ca_pinning.rb +++ b/test/test_disable_ca_pinning.rb @@ -65,3 +65,51 @@ def test_unpinned_client_does_not_pass_ca_file_to_http @client_unpinned.request('GET', '/foo/bar') end end + +class TestUserAgent < BaseTestCase + def test_user_agent_includes_ca_bundle_version + assert_include(@client.send(:user_agent), "ca_bundle/#{DuoApi::CA_BUNDLE_VERSION}") + end + + def test_user_agent_pinning_enabled_by_default + client = DuoApi.new(IKEY, SKEY, HOST) + assert_equal( + "duo_api_ruby/#{DuoApi::VERSION} ca_bundle/#{DuoApi::CA_BUNDLE_VERSION} (ca_pinning=enable)", + client.send(:user_agent) + ) + end + + def test_user_agent_pinning_disabled + client = DuoApi.new(IKEY, SKEY, HOST, nil, disable_ca_pinning: true) + assert_equal( + "duo_api_ruby/#{DuoApi::VERSION} ca_bundle/#{DuoApi::CA_BUNDLE_VERSION} (ca_pinning=disable)", + client.send(:user_agent) + ) + end +end + +class TestUserAgentHeader < 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 assert_user_agent_header(client, expected_state) + Net::HTTP.expects(:start).yields(@mock_http) + @mock_http.expects(:request).with do |request| + request['User-Agent'] == + "duo_api_ruby/#{DuoApi::VERSION} ca_bundle/#{DuoApi::CA_BUNDLE_VERSION} (ca_pinning=#{expected_state})" + end.returns(@ok_resp) + client.request('GET', '/foo/bar') + end + + def test_pinned_client_sets_user_agent_header + assert_user_agent_header(@client_pinned, 'enable') + end + + def test_unpinned_client_sets_user_agent_header + assert_user_agent_header(@client_unpinned, 'disable') + end +end