From 40fa39dd7426142628a369e8215a8006ed7bcbbe Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Thu, 16 Jul 2026 10:31:29 -0400 Subject: [PATCH 1/3] Prevent OS certificate store fallback when CA pinning is enabled Set CURLOPT_CAPATH to a non-existent directory (/dev/null/) when CA pinning is active, preventing the TLS library from falling back to the OS certificate store. Fail closed with a RuntimeException if the option cannot be applied. --- src/CurlRequester.php | 10 ++ tests/Unit/CAPinningTest.php | 156 +++++++++++++++++++++++++++++++ tests/Unit/CurlRequesterTest.php | 9 ++ 3 files changed, 175 insertions(+) create mode 100644 tests/Unit/CAPinningTest.php diff --git a/src/CurlRequester.php b/src/CurlRequester.php index 4b4e155..afb8d39 100644 --- a/src/CurlRequester.php +++ b/src/CurlRequester.php @@ -47,6 +47,16 @@ public function options($options) unset($curl_options[CURLOPT_CAINFO]); } + if (isset($curl_options[CURLOPT_CAINFO])) { + $capath = "/dev/null/" . \bin2hex(\random_bytes(16)); + if (!curl_setopt($this->ch, CURLOPT_CAPATH, $capath)) { + throw new \RuntimeException( + "Failed to set CURLOPT_CAPATH; CA pinning cannot be enforced on this cURL/TLS backend" + ); + } + $curl_options[CURLOPT_CAPATH] = $capath; + } + // Mandatory configuration options $curl_options[CURLOPT_RETURNTRANSFER] = 1; $curl_options[CURLOPT_FOLLOWLOCATION] = 1; diff --git a/tests/Unit/CAPinningTest.php b/tests/Unit/CAPinningTest.php new file mode 100644 index 0000000..62317d3 --- /dev/null +++ b/tests/Unit/CAPinningTest.php @@ -0,0 +1,156 @@ +ch = \curl_init(); + } + + public function options($options) + { + assert(is_array($options)); + + $possible_options = [ + CURLOPT_TIMEOUT => "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]); + } + + if (isset($curl_options[CURLOPT_CAINFO])) { + $capath = "/dev/null/" . \bin2hex(\random_bytes(16)); + $curl_options[CURLOPT_CAPATH] = $capath; + } + + $curl_options[CURLOPT_RETURNTRANSFER] = 1; + $curl_options[CURLOPT_FOLLOWLOCATION] = 1; + $curl_options[CURLOPT_SSL_VERIFYPEER] = true; + $curl_options[CURLOPT_SSL_VERIFYHOST] = 2; + + $this->lastCurlOptions = $curl_options; + } +} + +class CAPinningTest extends TestCase +{ + private const EXPECTED_CAPATH_PATTERN = '/^\/dev\/null\/[0-9a-f]{32}$/'; + + private function getOptionsFromCall(array $inputOptions): array + { + $requester = new CAPinningTestRequester(); + $requester->options($inputOptions); + return $requester->lastCurlOptions; + } + + // --------------------------------------------------------- + // CAPATH set when pinning is active (default) + // --------------------------------------------------------- + + public function testCaPathSetToDevNullRandomWhenDefaultPinning(): void + { + $options = $this->getOptionsFromCall([]); + + $this->assertArrayHasKey(CURLOPT_CAPATH, $options); + $this->assertMatchesRegularExpression( + self::EXPECTED_CAPATH_PATTERN, + $options[CURLOPT_CAPATH] + ); + } + + public function testCaPathSetWhenCustomCaProvided(): void + { + $options = $this->getOptionsFromCall(["ca" => "/custom/ca.pem"]); + + $this->assertArrayHasKey(CURLOPT_CAPATH, $options); + $this->assertMatchesRegularExpression( + self::EXPECTED_CAPATH_PATTERN, + $options[CURLOPT_CAPATH] + ); + } + + // --------------------------------------------------------- + // CAPATH NOT set when pinning is disabled + // --------------------------------------------------------- + + public function testCaPathNotSetWhenDisableCaPinning(): void + { + $options = $this->getOptionsFromCall(["disable_ca_pinning" => true]); + + $this->assertArrayNotHasKey(CURLOPT_CAPATH, $options); + } + + public function testCaPathNotSetWhenCaIsIgnore(): void + { + $options = $this->getOptionsFromCall(["ca" => "IGNORE"]); + + $this->assertArrayNotHasKey(CURLOPT_CAPATH, $options); + } + + // --------------------------------------------------------- + // CAINFO behavior unchanged + // --------------------------------------------------------- + + public function testDefaultCaInfoIsSetWhenNoCaOptionProvided(): void + { + $options = $this->getOptionsFromCall([]); + + $this->assertArrayHasKey(CURLOPT_CAINFO, $options); + $this->assertEquals(\DEFAULT_CA_CERTS, $options[CURLOPT_CAINFO]); + } + + public function testCaInfoNotSetWhenDisableCaPinning(): void + { + $options = $this->getOptionsFromCall(["disable_ca_pinning" => true]); + + $this->assertArrayNotHasKey(CURLOPT_CAINFO, $options); + } + + public function testCaInfoNotSetWhenCaIsIgnore(): void + { + $options = $this->getOptionsFromCall(["ca" => "IGNORE"]); + + $this->assertArrayNotHasKey(CURLOPT_CAINFO, $options); + } + + // --------------------------------------------------------- + // SSL verification always enforced + // --------------------------------------------------------- + + public function testSslVerifyPeerAlwaysTrue(): void + { + $options = $this->getOptionsFromCall([]); + $this->assertTrue($options[CURLOPT_SSL_VERIFYPEER]); + } + + public function testSslVerifyHostAlwaysTwo(): void + { + $options = $this->getOptionsFromCall([]); + $this->assertEquals(2, $options[CURLOPT_SSL_VERIFYHOST]); + } +} diff --git a/tests/Unit/CurlRequesterTest.php b/tests/Unit/CurlRequesterTest.php index 67d8cd1..7da2173 100644 --- a/tests/Unit/CurlRequesterTest.php +++ b/tests/Unit/CurlRequesterTest.php @@ -5,6 +5,11 @@ class TestableCurlRequester extends \DuoAPI\CurlRequester { public $applied_options = []; + public function __construct() + { + $this->ch = \curl_init(); + } + public function options($options) { assert(is_array($options)); @@ -35,6 +40,10 @@ public function options($options) unset($curl_options[CURLOPT_CAINFO]); } + if (isset($curl_options[CURLOPT_CAINFO])) { + $curl_options[CURLOPT_CAPATH] = "/dev/null/" . bin2hex(\random_bytes(16)); + } + $curl_options[CURLOPT_RETURNTRANSFER] = 1; $curl_options[CURLOPT_FOLLOWLOCATION] = 1; $curl_options[CURLOPT_SSL_VERIFYPEER] = true; From cbd1d765aa5b0be0da09765c9c474b5b55c6d7e1 Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Thu, 16 Jul 2026 10:32:33 -0400 Subject: [PATCH 2/3] Remove FileRequester The curl extension is already a hard requirement in composer.json, making the file_get_contents fallback transport unreachable. Removing it simplifies the codebase and eliminates a transport that cannot provide the same CA pinning guarantees as CurlRequester. --- src/Client.php | 4 +- src/FileRequester.php | 150 ------------------------------------------ tests/SSL/SSLTest.php | 67 ------------------- 3 files changed, 1 insertion(+), 220 deletions(-) delete mode 100644 src/FileRequester.php diff --git a/src/Client.php b/src/Client.php index bd6a9c3..e46be6c 100644 --- a/src/Client.php +++ b/src/Client.php @@ -42,10 +42,8 @@ public function __construct( if ($requester !== null) { $this->requester = $requester; - } elseif (in_array("curl", get_loaded_extensions(), true)) { - $this->requester = new CurlRequester(); } else { - $this->requester = new FileRequester(); + $this->requester = new CurlRequester(); } $this->paging = $paging; diff --git a/src/FileRequester.php b/src/FileRequester.php deleted file mode 100644 index 91a1bf6..0000000 --- a/src/FileRequester.php +++ /dev/null @@ -1,150 +0,0 @@ -http_options = [ - "http" => [ - /* - * We'll handle HTTP errors on our own - */ - "ignore_errors" => true, - ], - "ssl" => [ - /* - * Disallow self-signed certificates - */ - "allow_self_signed" => false, - /* - * Enforce CN verification - */ - "verify_peer" => true, - /* - * Avoid compression (CRIME attack) - */ - "disable_compression" => true, - /* - * Require good ciphers. View the list with: - * - * openssl ciphers -v 'HIGH:!SSLv2:!SSLv3' - */ - "ciphers" => "HIGH:!SSLv2:!SSLv3", - ], - ]; - } - - public function __destruct() - { - } - - protected static function parse_http_response_header($headers) - { - /* - * It's possible that there will be multiple HTTP status codes in - * our array. For example, this can happen when we're redirected so - * we receive a 301 then 200. We should take the last status code - * received. - */ - $status_code_regex = '#^HTTP/\d\.\d[ \t]+(?\d+)#i'; - - $status_code_headers = preg_grep($status_code_regex, $headers); - $status_codes = array_map( - function ($header) use ($status_code_regex) { - $status_code = preg_match($status_code_regex, $header, $matches); - return (int) $matches['http_status_code']; - }, - $status_code_headers - ); - - return end($status_codes); - } - - public function options($options) - { - assert(is_array($options)); - - if (isset($options["user_agent"])) { - $this->http_options["http"]["user_agent"] = $options["user_agent"]; - } - if (isset($options["timeout"])) { - $this->http_options["http"]["timeout"] = $options["timeout"]; - } - if (isset($options["proxy_url"])) { - $uri = $options["proxy_url"]; - $uri .= (isset($options["proxy_port"]) ? ":" . $options["proxy_port"] : ""); - $this->http_options["http"]["proxy"] = $uri; - } - $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"]; - } - } - - public function execute($url, $method, $headers, $body = null) - { - assert(is_string($url)); - assert(is_string($method)); - assert(is_array($headers)); - assert(is_string($body) || is_null($body)); - - $headers = array_map(function ($key, $value) { - return sprintf("%s: %s", $key, $value); - }, array_keys($headers), array_values($headers)); - - $this->http_options['http']['method'] = $method; - $this->http_options['http']['header'] = $headers; - - if ($method === "POST") { - $this->http_options['http']['content'] = $body; - } - - $context = stream_context_create($this->http_options); - - $result = @file_get_contents($url, false, $context); - - $http_status_code = null; - $success = true; - if ($result === false) { - $error = error_get_last(); - $errno = $error["type"]; - $message = $error["message"]; - - /** - * We could simply leave the result as FALSE and return that, but - * let's convert it to what looks like an actual Duo web response. - * This is beneficial because it simplifies the two error cases - * we expect: - * - * 1. We had some sort of malformed request and Duo rejected it. - * - * 2. We couldn't reach Duo (this is the case we'd expect to - * return FALSE). - */ - $result = json_encode( - [ - 'stat' => 'FAIL', - 'code' => $errno, - 'message' => $message, - ] - ); - $success = false; - } else { - 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); - } - - return [ - "response" => $result, - "success" => $success, - "http_status_code" => $http_status_code - ]; - } -} diff --git a/tests/SSL/SSLTest.php b/tests/SSL/SSLTest.php index 727e6f9..2421830 100644 --- a/tests/SSL/SSLTest.php +++ b/tests/SSL/SSLTest.php @@ -108,23 +108,6 @@ public function testCorrectlySignedCertificateCurl() $this->assertTrue($result["success"]); } - public function testCorrectlySignedCertificateFile() - { - $requester = new \DuoAPI\FileRequester(); - $result = $this->pingSSLServer( - $requester, - GOOD_STUNNEL_SERVER, - $this->good_chain - ); - - /* - * A '404' here is fine. We're simply trying to test if a good - * SSL *connection* is made, there's not a fully implemented API - * waiting for us on the other side of the connection. - */ - $this->assertEquals(404, $result["http_status_code"]); - } - /* * Test our custom certificate that was signed by our custom CA against * a third-party certificate chain. @@ -153,23 +136,6 @@ public function testMismatchedCertificateCurl() ); } - public function testMismatchedCertificateFile() - { - $requester = new \DuoAPI\FileRequester(); - $result = $this->pingSSLServer( - $requester, - GOOD_STUNNEL_SERVER, - $this->bad_chain - ); - - $this->assertFalse($result["success"]); - $this->assertEquals($result["response"]["stat"], "FAIL"); - $this->assertStringContainsStringIgnoringCase( - "failed to open stream: operation failed", - $result["response"]["message"] - ); - } - /* * Test an unsigned certificate against our custom certificate chain. * @@ -195,23 +161,6 @@ public function testSelfSignedCertificateCurl() ); } - public function testSelfSignedCertificateFile() - { - $requester = new \DuoAPI\FileRequester(); - $result = $this->pingSSLServer( - $requester, - SELF_SIGNED_STUNNEL_SERVER, - $this->good_chain - ); - - $this->assertFalse($result["success"]); - $this->assertEquals($result["response"]["stat"], "FAIL"); - $this->assertStringContainsStringIgnoringCase( - "failed to open stream: operation failed", - $result["response"]["message"] - ); - } - /* * Test a custom certificate with an incorrect hostname that was signed * by our custom CA against the certificate chain created by our custom CA. @@ -238,20 +187,4 @@ public function testCertificateBadHostnameCurl() ); } - public function testCertificateBadHostnameFile() - { - $requester = new \DuoAPI\FileRequester(); - $result = $this->pingSSLServer( - $requester, - BAD_HOSTNAME_STUNNEL_SERVER, - $this->good_chain - ); - - $this->assertFalse($result["success"]); - $this->assertEquals($result["response"]["stat"], "FAIL"); - $this->assertStringContainsStringIgnoringCase( - "failed to open stream: operation failed", - $result["response"]["message"] - ); - } } From 67db1a3807550bf977e9b36c1d723dba560959a8 Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Thu, 16 Jul 2026 11:27:05 -0400 Subject: [PATCH 3/3] Normalize ca=IGNORE to disable_ca_pinning at the Client layer Move the legacy ca="IGNORE" handling out of CurlRequester and into Client::setRequesterOption(), where it sets disable_ca_pinning=true and removes the ca option. This ensures consistent behavior: the User-Agent correctly reports ca_pinning=disabled, and CurlRequester no longer needs awareness of the IGNORE magic string. --- src/Client.php | 6 ++++++ src/CurlRequester.php | 2 -- tests/Unit/CAPinningTest.php | 17 ++++++++++------- tests/Unit/CurlRequesterTest.php | 11 +++++------ 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/Client.php b/src/Client.php index e46be6c..280d19f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -67,6 +67,12 @@ public function __construct( */ public function setRequesterOption($option, $value) { + // Normalize the legacy ca=IGNORE flag to disable_ca_pinning + if ($option === "ca" && $value === "IGNORE") { + $this->options["disable_ca_pinning"] = true; + unset($this->options["ca"]); + return $this; + } 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" diff --git a/src/CurlRequester.php b/src/CurlRequester.php index afb8d39..7273d92 100644 --- a/src/CurlRequester.php +++ b/src/CurlRequester.php @@ -43,8 +43,6 @@ public function options($options) 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]); } if (isset($curl_options[CURLOPT_CAINFO])) { diff --git a/tests/Unit/CAPinningTest.php b/tests/Unit/CAPinningTest.php index 62317d3..984fb0a 100644 --- a/tests/Unit/CAPinningTest.php +++ b/tests/Unit/CAPinningTest.php @@ -39,8 +39,6 @@ public function options($options) 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]); } if (isset($curl_options[CURLOPT_CAINFO])) { @@ -105,11 +103,13 @@ public function testCaPathNotSetWhenDisableCaPinning(): void $this->assertArrayNotHasKey(CURLOPT_CAPATH, $options); } - public function testCaPathNotSetWhenCaIsIgnore(): void + public function testCaPathNotSetWhenCaIsIgnoreViaCLient(): void { - $options = $this->getOptionsFromCall(["ca" => "IGNORE"]); + $client = new \DuoAPI\Client("IKEY", "SKEY", "host.example.com"); + $client->setRequesterOption("ca", "IGNORE"); - $this->assertArrayNotHasKey(CURLOPT_CAPATH, $options); + $this->assertTrue($client->options["disable_ca_pinning"]); + $this->assertArrayNotHasKey("ca", $client->options); } // --------------------------------------------------------- @@ -131,11 +131,14 @@ public function testCaInfoNotSetWhenDisableCaPinning(): void $this->assertArrayNotHasKey(CURLOPT_CAINFO, $options); } - public function testCaInfoNotSetWhenCaIsIgnore(): void + public function testIgnoreNormalizesToDisableCaPinning(): void { - $options = $this->getOptionsFromCall(["ca" => "IGNORE"]); + $client = new \DuoAPI\Client("IKEY", "SKEY", "host.example.com"); + $client->setRequesterOption("ca", "IGNORE"); + $options = $this->getOptionsFromCall($client->options); $this->assertArrayNotHasKey(CURLOPT_CAINFO, $options); + $this->assertArrayNotHasKey(CURLOPT_CAPATH, $options); } // --------------------------------------------------------- diff --git a/tests/Unit/CurlRequesterTest.php b/tests/Unit/CurlRequesterTest.php index 7da2173..9971b64 100644 --- a/tests/Unit/CurlRequesterTest.php +++ b/tests/Unit/CurlRequesterTest.php @@ -36,8 +36,6 @@ public function options($options) 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]); } if (isset($curl_options[CURLOPT_CAINFO])) { @@ -90,11 +88,12 @@ public function testCustomCaIsUsed() $this->assertEquals("/custom/ca.pem", $requester->applied_options[CURLOPT_CAINFO]); } - public function testIgnoreCaRemovesCaInfo() + public function testIgnoreNormalizedByClient() { - $requester = new TestableCurlRequester(); - $requester->options(["timeout" => 10, "ca" => "IGNORE"]); + $client = new \DuoAPI\Client("IKEY", "SKEY", "host.example.com"); + $client->setRequesterOption("ca", "IGNORE"); - $this->assertArrayNotHasKey(CURLOPT_CAINFO, $requester->applied_options); + $this->assertTrue($client->options["disable_ca_pinning"]); + $this->assertArrayNotHasKey("ca", $client->options); } }