From 3a0e2f67b8a5e82cf0ce689adc91ad0e6ae8d1e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20R=C3=BCtter?= Date: Wed, 22 Jul 2026 15:04:48 +0200 Subject: [PATCH] Fix TLS certificate validation bypass in non-cURL HTTP path (CWE-295) Two separate bugs in the non-cURL (file_get_contents/stream context) request path, only reachable when the curl extension is unavailable or setUseCurl(false) is called explicitly: 1. _getNoCurlInitialization() nested the SSL options under $httpOptions['ssl'], which array_merge_recursive() then placed at $options['http']['ssl']. PHP's https:// stream wrapper only reads SSL context options from the top-level 'ssl' key, so the intended cafile/verify_depth/peer_name settings were silently ignored. 2. _fetchRemoteFile() explicitly set a top-level 'ssl' => ['verify_peer' => false] for its media-download request, unconditionally disabling certificate validation. Confirmed exploitable with a local TLS server presenting a self-signed certificate for the correct hostname. Fix: - Keep the SSL options as a sibling top-level context key instead of nesting them under 'http', and assign them directly rather than through the recursive merge (a future caller passing its own 'ssl' key could otherwise turn a boolean into an array on collision). - Remove the explicit verify_peer => false override in _fetchRemoteFile(), so it inherits the secure defaults. - Pass the request hostname into _getNoCurlInitialization() from _fetchRemoteFile() (via parse_url), matching the other two call sites. Without it, peer_name became an explicit empty string instead of being unset, which broke certificate validation for every HTTPS download, not just malicious ones. Caught while verifying the first version of this fix against a real HTTPS URL. - Set verify_peer_name explicitly, matching PHP's own 5.6+ default. - Bump the composer.json PHP floor from 5.5.0 to 5.6.0, since peer_name/automatic hostname verification require it (5.5 only had the deprecated CN_match option). The README already documents PHP 7.1+ as the real requirement. Verification: the project's PHPUnit suite doesn't run under PHPUnit 13 (legacy test-class naming isn't discovered), so this was verified with a standalone script using reflection to call both methods against a local openssl s_server: - self-signed certificate, hostname-matching but untrusted: rejected by both _getNoCurlInitialization() and _fetchRemoteFile() - real HTTPS request (https://www.google.com/robots.txt): succeeds through both, confirming no regression on legitimate requests Fixes #275 --- composer.json | 2 +- src/codebird.php | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 586f39c..3e0838e 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "source": "https://github.com/jublonet/codebird-php/releases" }, "require": { - "php": ">=5.5.0", + "php": ">=5.6.0", "ext-hash": "*", "ext-json": "*", "lib-openssl": "*", diff --git a/src/codebird.php b/src/codebird.php index 83301ee..53383b3 100644 --- a/src/codebird.php +++ b/src/codebird.php @@ -1165,11 +1165,12 @@ protected function _getNoCurlInitialization($url, $contextOptions, $hostname = ' 'User-Agent: codebird-php/' . $this->getVersion() . ' +https://github.com/jublonet/codebird-php' ]; - $httpOptions['ssl'] = [ - 'verify_peer' => true, - 'cafile' => __DIR__ . '/cacert.pem', - 'verify_depth' => 5, - 'peer_name' => $hostname + $sslOptions = [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'cafile' => __DIR__ . '/cacert.pem', + 'verify_depth' => 5, + 'peer_name' => $hostname ]; if ($this->_hasProxy()) { @@ -1188,6 +1189,11 @@ protected function _getNoCurlInitialization($url, $contextOptions, $hostname = ' ['http' => $httpOptions] ); + // 'ssl' must stay a top-level context key (stream_context_create() ignores + // it if nested under 'http') and isn't caller-overridable, so it's assigned + // directly rather than merged + $options['ssl'] = $sslOptions; + // concatenate $options['http']['header'] $options['http']['header'] = implode("\r\n", $options['http']['header']); @@ -1905,7 +1911,7 @@ protected function _fetchRemoteFile($url) $connection = $this->_getCurlInitialization($url); $this->_curl_setopt($connection, CURLOPT_RETURNTRANSFER, 1); $this->_curl_setopt($connection, CURLOPT_HEADER, 0); - // no SSL validation for downloading media + // enforce SSL validation for downloading media $this->_curl_setopt($connection, CURLOPT_SSL_VERIFYPEER, 1); $this->_curl_setopt($connection, CURLOPT_SSL_VERIFYHOST, 2); $this->_curl_setopt($connection, CURLOPT_TIMEOUT_MS, $this->_timeouts['remote']); @@ -1924,17 +1930,15 @@ protected function _fetchRemoteFile($url) return false; } // no cURL + $hostname = parse_url($url, PHP_URL_HOST); $contextOptions = [ 'http' => [ 'method' => 'GET', 'protocol_version' => '1.1', 'timeout' => $this->_timeouts['remote'] - ], - 'ssl' => [ - 'verify_peer' => false ] ]; - list($result, $headers) = $this->_getNoCurlInitialization($url, $contextOptions); + list($result, $headers) = $this->_getNoCurlInitialization($url, $contextOptions, $hostname); if ($result !== false && preg_match('/^HTTP\/\d\.\d 200 OK$/', $headers[0]) ) {