From 692f2fc9620615f8a60e7ba5ad386c943a2dd8d5 Mon Sep 17 00:00:00 2001 From: Dmytro Hissa Date: Thu, 2 Jul 2026 17:53:28 +0200 Subject: [PATCH 1/3] Add an option to disable CA certificate pinning --- src/Client.php | 16 ++++++ src/CurlRequester.php | 12 +++-- src/FileRequester.php | 7 +-- tests/Unit/ClientTest.php | 50 ++++++++++++++++++ tests/Unit/CurlRequesterTest.php | 91 ++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/CurlRequesterTest.php diff --git a/src/Client.php b/src/Client.php index 08cef7c..11f55a8 100644 --- a/src/Client.php +++ b/src/Client.php @@ -68,10 +68,26 @@ public function __construct( */ public function setRequesterOption($option, $value) { + if ($option === "ca" && isset($this->options["disable_ca_pinning"]) && $this->options["disable_ca_pinning"]) { + throw new \InvalidArgumentException( + "Cannot use custom CA certificates when CA pinning is disabled" + ); + } $this->options[$option] = $value; return $this; } + public function disableCaPinning() + { + if (isset($this->options["ca"])) { + throw new \InvalidArgumentException( + "Cannot disable CA pinning when custom CA certificates are set" + ); + } + $this->options["disable_ca_pinning"] = true; + return $this; + } + private function signParameters($method, $host, $path, $params, $skey, $ikey, $now, $body, $additional_headers) { assert(is_string($method)); diff --git a/src/CurlRequester.php b/src/CurlRequester.php index 3abf5ed..4b4e155 100644 --- a/src/CurlRequester.php +++ b/src/CurlRequester.php @@ -37,13 +37,15 @@ public function options($options) $curl_options[$key] = $options[$value]; } - if (!isset($curl_options[CURLOPT_CAINFO])) { - $curl_options[CURLOPT_CAINFO] = DEFAULT_CA_CERTS; - }; + $ca_pinning_disabled = isset($options["disable_ca_pinning"]) && $options["disable_ca_pinning"]; - if ($curl_options[CURLOPT_CAINFO] == "IGNORE") { + if ($ca_pinning_disabled) { + unset($curl_options[CURLOPT_CAINFO]); + } elseif (!isset($curl_options[CURLOPT_CAINFO])) { + $curl_options[CURLOPT_CAINFO] = DEFAULT_CA_CERTS; + } elseif ($curl_options[CURLOPT_CAINFO] == "IGNORE") { unset($curl_options[CURLOPT_CAINFO]); - }; + } // Mandatory configuration options $curl_options[CURLOPT_RETURNTRANSFER] = 1; diff --git a/src/FileRequester.php b/src/FileRequester.php index 88831b8..a81067b 100644 --- a/src/FileRequester.php +++ b/src/FileRequester.php @@ -78,7 +78,8 @@ public function options($options) $uri .= (isset($options["proxy_port"]) ? ":" . $options["proxy_port"] : ""); $this->http_options["http"]["proxy"] = $uri; } - if (isset($options["ca"])) { + $ca_pinning_disabled = isset($options["disable_ca_pinning"]) && $options["disable_ca_pinning"]; + if (!$ca_pinning_disabled && isset($options["ca"])) { $this->http_options["ssl"]["cafile"] = $options["ca"]; } } @@ -132,8 +133,8 @@ public function execute($url, $method, $headers, $body = null) ); $success = false; } else { - // https://secure.php.net/manual/en/reserved.variables.httpresponseheader.php - $http_status_code = self::parse_http_response_header($http_response_header); + $response_headers = http_get_last_response_headers(); + $http_status_code = self::parse_http_response_header($response_headers); } return [ diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 72167c1..416f127 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -502,4 +502,54 @@ public function testUserAgent() ); $response = $client->apiCall("GET", "/foo/bar", []); } + + public function testDisableCaPinning() + { + $client = new \DuoAPI\Client("TEST", "TEST", "TEST"); + $result = $client->disableCaPinning(); + + $this->assertSame($client, $result); + $this->assertTrue($client->options["disable_ca_pinning"]); + } + + public function testDisableCaPinningWithCustomCaThrows() + { + $client = new \DuoAPI\Client("TEST", "TEST", "TEST"); + $client->setRequesterOption("ca", "/path/to/cert.pem"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Cannot disable CA pinning when custom CA certificates are set"); + $client->disableCaPinning(); + } + + public function testSetCustomCaWithDisabledPinningThrows() + { + $client = new \DuoAPI\Client("TEST", "TEST", "TEST"); + $client->disableCaPinning(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Cannot use custom CA certificates when CA pinning is disabled"); + $client->setRequesterOption("ca", "/path/to/cert.pem"); + } + + public function testDisableCaPinningSetsOption() + { + $success_resp = [ + "success" => true, + "response" => "ok", + "http_status_code" => 200, + ]; + + $response = [$success_resp]; + $client = self::getMockedClient("Client", $response, $paged = true); + $client->disableCaPinning(); + + $this->mocked_curl_requester->expects($this->atLeastOnce()) + ->method('options') + ->with($this->callback(function($options) { + return isset($options["disable_ca_pinning"]) && $options["disable_ca_pinning"] === true; + })); + + $client->apiCall("GET", "/foo/bar", []); + } } diff --git a/tests/Unit/CurlRequesterTest.php b/tests/Unit/CurlRequesterTest.php new file mode 100644 index 0000000..67d8cd1 --- /dev/null +++ b/tests/Unit/CurlRequesterTest.php @@ -0,0 +1,91 @@ + "timeout", + CURLOPT_CAINFO => "ca", + CURLOPT_USERAGENT => "user_agent", + CURLOPT_PROXY => "proxy_url", + CURLOPT_PROXYPORT => "proxy_port", + ]; + + $curl_options = array_filter($possible_options, function ($option) use ($options) { + return array_key_exists($option, $options); + }); + + foreach ($curl_options as $key => $value) { + $curl_options[$key] = $options[$value]; + } + + $ca_pinning_disabled = isset($options["disable_ca_pinning"]) && $options["disable_ca_pinning"]; + + if ($ca_pinning_disabled) { + unset($curl_options[CURLOPT_CAINFO]); + } elseif (!isset($curl_options[CURLOPT_CAINFO])) { + $curl_options[CURLOPT_CAINFO] = DEFAULT_CA_CERTS; + } elseif ($curl_options[CURLOPT_CAINFO] == "IGNORE") { + unset($curl_options[CURLOPT_CAINFO]); + } + + $curl_options[CURLOPT_RETURNTRANSFER] = 1; + $curl_options[CURLOPT_FOLLOWLOCATION] = 1; + $curl_options[CURLOPT_SSL_VERIFYPEER] = true; + $curl_options[CURLOPT_SSL_VERIFYHOST] = 2; + + $this->applied_options = $curl_options; + curl_setopt_array($this->ch, $curl_options); + } +} + +class CurlRequesterTest extends \PHPUnit\Framework\TestCase +{ + public function testDefaultOptionsIncludeCaCerts() + { + $requester = new TestableCurlRequester(); + $requester->options(["timeout" => 10]); + + $this->assertArrayHasKey(CURLOPT_CAINFO, $requester->applied_options); + $this->assertEquals(DEFAULT_CA_CERTS, $requester->applied_options[CURLOPT_CAINFO]); + } + + public function testDisableCaPinningRemovesCaInfo() + { + $requester = new TestableCurlRequester(); + $requester->options(["timeout" => 10, "disable_ca_pinning" => true]); + + $this->assertArrayNotHasKey(CURLOPT_CAINFO, $requester->applied_options); + } + + public function testDisableCaPinningKeepsSslVerification() + { + $requester = new TestableCurlRequester(); + $requester->options(["timeout" => 10, "disable_ca_pinning" => true]); + + $this->assertTrue($requester->applied_options[CURLOPT_SSL_VERIFYPEER]); + $this->assertEquals(2, $requester->applied_options[CURLOPT_SSL_VERIFYHOST]); + } + + public function testCustomCaIsUsed() + { + $requester = new TestableCurlRequester(); + $requester->options(["timeout" => 10, "ca" => "/custom/ca.pem"]); + + $this->assertEquals("/custom/ca.pem", $requester->applied_options[CURLOPT_CAINFO]); + } + + public function testIgnoreCaRemovesCaInfo() + { + $requester = new TestableCurlRequester(); + $requester->options(["timeout" => 10, "ca" => "IGNORE"]); + + $this->assertArrayNotHasKey(CURLOPT_CAINFO, $requester->applied_options); + } +} From aa472ac7d13d7a721b4fdd73fea1aa315de44c09 Mon Sep 17 00:00:00 2001 From: Dmytro Hissa Date: Thu, 2 Jul 2026 18:05:52 +0200 Subject: [PATCH 2/3] Fix PHP 8.5 compatibility for http_response_header deprecation --- src/FileRequester.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/FileRequester.php b/src/FileRequester.php index a81067b..91a1bf6 100644 --- a/src/FileRequester.php +++ b/src/FileRequester.php @@ -133,7 +133,11 @@ public function execute($url, $method, $headers, $body = null) ); $success = false; } else { - $response_headers = http_get_last_response_headers(); + if (function_exists('http_get_last_response_headers')) { + $response_headers = http_get_last_response_headers(); + } else { + $response_headers = $http_response_header; + } $http_status_code = self::parse_http_response_header($response_headers); } From 37b2bdabe028e8a62fa7a49508752c69d3fc379c Mon Sep 17 00:00:00 2001 From: Dmytro Hissa Date: Thu, 9 Jul 2026 11:16:05 +0200 Subject: [PATCH 3/3] Add CA bundle version and pinning status to the user agent string --- src/Client.php | 6 +++++- tests/Unit/ClientTest.php | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Client.php b/src/Client.php index 11f55a8..bd6a9c3 100644 --- a/src/Client.php +++ b/src/Client.php @@ -4,6 +4,7 @@ use DateTime; const VERSION = "1.2.1-dev"; +const CA_BUNDLE_VERSION = "1.0"; const INITIAL_BACKOFF_SECONDS = 1; const MAX_BACKOFF_SECONDS = 32; const BACKOFF_FACTOR = 2; @@ -240,7 +241,10 @@ public function apiCall($method, $path, $params, $additional_headers = []) } $headers["Date"] = $now; - $headers["User-Agent"] = "duo_api_php/" . VERSION; + $ca_pinning_status = (!empty($this->options["disable_ca_pinning"])) ? "disabled" : "enabled"; + $headers["User-Agent"] = "duo_api_php/" . VERSION + . " ca_bundle/" . CA_BUNDLE_VERSION + . " (ca_pinning=" . $ca_pinning_status . ")"; $headers["Authorization"] = self::signParameters( $method, $this->host, diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 416f127..b5adefd 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -493,9 +493,39 @@ public function testUserAgent() $this->anything(), $this->anything(), $this->callback(function($headers) { - $version = "duo_api_php/" . \DuoAPI\VERSION; + $expected = "duo_api_php/" . \DuoAPI\VERSION + . " ca_bundle/" . \DuoAPI\CA_BUNDLE_VERSION + . " (ca_pinning=enabled)"; $this->assertArrayHasKey("User-Agent", $headers); - $this->assertEquals($headers["User-Agent"], $version); + $this->assertEquals($expected, $headers["User-Agent"]); + return true; + }), + $this->anything() + ); + $response = $client->apiCall("GET", "/foo/bar", []); + } + + public function testUserAgentWithCaPinningDisabled() + { + $success_resp = [ + "success" => true, + "response" => "not rate limited", + "http_status_code" => 200, + ]; + + $response = [$success_resp]; + + $client = self::getMockedClient("Client", $response, $paged = true); + $client->disableCaPinning(); + $this->mocked_curl_requester->method('execute')->with( + $this->anything(), + $this->anything(), + $this->callback(function($headers) { + $expected = "duo_api_php/" . \DuoAPI\VERSION + . " ca_bundle/" . \DuoAPI\CA_BUNDLE_VERSION + . " (ca_pinning=disabled)"; + $this->assertArrayHasKey("User-Agent", $headers); + $this->assertEquals($expected, $headers["User-Agent"]); return true; }), $this->anything()