From 7e2a015e9ba998ced1d7865cd4b5ff1193174ab2 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 19 Jan 2016 23:54:23 +0000 Subject: [PATCH 1/6] Provide support for subjectAltName on PHP < 5.6 --- src/Composer/Util/RemoteFilesystem.php | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 9a8a25d81..b3bd6030f 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -33,6 +33,7 @@ class RemoteFilesystem private $progress; private $lastProgress; private $options = array(); + private $peerCertificateMap = array(); private $disableTls = false; private $retryAuthFailure; private $lastHeaders; @@ -252,6 +253,18 @@ class RemoteFilesystem }); try { $result = file_get_contents($fileUrl, false, $ctx); + + if (PHP_VERSION_ID < 50600 && !empty($options['ssl']['peer_fingerprint'])) { + // Emulate fingerprint validation on PHP < 5.6 + $params = stream_context_get_params($ctx); + $expectedPeerFingerprint = $options['ssl']['peer_fingerprint']; + $peerFingerprint = $this->getCertificateFingerprint($params['options']['ssl']['peer_certificate']); + + // Constant time compare??! + if ($expectedPeerFingerprint !== $peerFingerprint) { + throw new TransportException('Peer fingerprint did not match'); + } + } } catch (\Exception $e) { if ($e instanceof TransportException && !empty($http_response_header[0])) { $e->setHeaders($http_response_header); @@ -353,6 +366,32 @@ class RemoteFilesystem } } + if (false === $result && false !== strpos($errorMessage, 'Peer certificate') && PHP_VERSION_ID < 50600) { + // Certificate name error, PHP doesn't support subjectAltName on PHP < 5.6 + // The procedure to handle sAN for older PHP's is: + // + // 1. Open socket to remote server and fetch certificate (disabling peer + // validation because PHP errors without giving up the certificate.) + // + // 2. Verifying the domain in the URL against the names in the sAN field. + // If there is a match record the authority [host/port], certificate + // common name, and certificate fingerprint. + // + // 3. Retry the original request but changing the CN_match parameter to + // the common name extracted from the certificate in step 2. + // + // 4. To prevent any attempt at being hoodwinked by switching the + // certificate between steps 2 and 3 the fingerprint of the certificate + // presented in step 3 is compared against the one recorded in step 2. + $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options); + + if ($certDetails) { + $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails; + + $this->retry = true; + } + } + if ($this->retry) { $this->retry = false; @@ -529,6 +568,14 @@ class RemoteFilesystem $tlsOptions['ssl']['SNI_server_name'] = $host; } + if (isset($this->peerCertificateMap[$this->getUrlAuthority($originUrl)])) { + // Handle subjectAltName on lesser PHP's. + $certMap = $this->peerCertificateMap[$this->getUrlAuthority($originUrl)]; + + $tlsOptions['ssl']['CN_match'] = $certMap['cn']; + $tlsOptions['ssl']['peer_fingerprint'] = $certMap['fp']; + } + $headers = array(); if (extension_loaded('zlib')) { @@ -625,6 +672,7 @@ class RemoteFilesystem 'verify_peer' => true, 'verify_depth' => 7, 'SNI_enabled' => true, + 'capture_peer_cert' => true, ) ); @@ -800,4 +848,113 @@ class RemoteFilesystem unset($source, $target); } + + private function getCertificateCnAndFp($url, $options) + { + $context = StreamContextFactory::getContext($url, $options, array('options' => array( + 'ssl' => array( + 'capture_peer_cert' => true, + 'verify_peer' => false, // Yes this is fucking insane! But PHP is lame. + )) + )); + + if (false === $handle = @fopen($url, 'rb', false, $context)) { + return; + } + + // Close non authenticated connection without reading any content. + fclose($handle); + + $params = stream_context_get_params($context); + + if (!empty($params['options']['ssl']['peer_certificate'])) { + $peerCertificate = $params['options']['ssl']['peer_certificate']; + + $fp = $this->getCertificateFingerprint($peerCertificate); + $cert = openssl_x509_parse($peerCertificate, false); + $commonName = $cert['subject']['commonName']; + + $subjectAltName = preg_split('{\s*,\s*}', $cert['extensions']['subjectAltName']); + $subjectAltName = array_filter(array_map(function ($name) { + if (0 === strpos($name, 'DNS:')) { + return substr($name, 4); + } + }, $subjectAltName)); + + if (in_array(parse_url($url, PHP_URL_HOST), $subjectAltName, true)) { + return array( + 'cn' => $commonName, + 'fp' => $fp, + ); + } + + // TODO: Support wildcards. + } + } + + /** + * Get the certificate pin. + * + * By Kevin McArthur of StormTide Digital Studios Inc. + * @KevinSMcArthur / https://github.com/StormTide + * + * See http://tools.ietf.org/html/draft-ietf-websec-key-pinning-02 + * + * This method was adapted from Sslurp. + * https://github.com/EvanDotPro/Sslurp + * + * (c) Evan Coury + * + * For the full copyright and license information, please see below: + * + * Copyright (c) 2013, Evan Coury + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + private function getCertificateFingerprint($certificate) + { + $pubkeydetails = openssl_pkey_get_details(openssl_get_publickey($certificate)); + $pubkeypem = $pubkeydetails['key']; + //Convert PEM to DER before SHA1'ing + $start = '-----BEGIN PUBLIC KEY-----'; + $end = '-----END PUBLIC KEY-----'; + $pemtrim = substr($pubkeypem, (strpos($pubkeypem, $start) + strlen($start)), (strlen($pubkeypem) - strpos($pubkeypem, $end)) * (-1)); + $der = base64_decode($pemtrim); + + return sha1($der); + } + + private function getUrlAuthority($url) + { + $defaultPorts = array( + 'ftp' => 21, + 'http' => 80, + 'https' => 443, + ); + + $defaultPort = $defaultPorts[parse_url($this->fileUrl, PHP_URL_SCHEME)]; + $port = parse_url($this->fileUrl, PHP_URL_PORT) ?: $defaultPort; + + return parse_url($this->fileUrl, PHP_URL_HOST).':'.$port; + } } From 304c268c3bf6bfee2b5320be25bd43a329fd1192 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sun, 24 Jan 2016 18:52:09 +0000 Subject: [PATCH 2/6] Tidy up and general improvement of sAN handling code * Move OpenSSL functions into a new TlsHelper class * Add error when sAN certificate cannot be verified due to CVE-2013-6420 * Throw exception if PHP >= 5.6 manages to use fallback code * Add support for wildcards in CN/sAN * Add tests for cert name validation * Check for backported security fix for CVE-2013-6420 using testcase from PHP tests. * Whitelist some disto PHP versions that have the CVE-2013-6420 fix backported. --- src/Composer/Util/RemoteFilesystem.php | 138 ++++------ src/Composer/Util/TlsHelper.php | 289 +++++++++++++++++++++ tests/Composer/Test/Util/TlsHelperTest.php | 76 ++++++ 3 files changed, 422 insertions(+), 81 deletions(-) create mode 100644 src/Composer/Util/TlsHelper.php create mode 100644 tests/Composer/Test/Util/TlsHelperTest.php diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index b3bd6030f..cee0670c0 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -258,7 +258,7 @@ class RemoteFilesystem // Emulate fingerprint validation on PHP < 5.6 $params = stream_context_get_params($ctx); $expectedPeerFingerprint = $options['ssl']['peer_fingerprint']; - $peerFingerprint = $this->getCertificateFingerprint($params['options']['ssl']['peer_certificate']); + $peerFingerprint = TlsHelper::getCertificateFingerprint($params['options']['ssl']['peer_certificate']); // Constant time compare??! if ($expectedPeerFingerprint !== $peerFingerprint) { @@ -383,12 +383,19 @@ class RemoteFilesystem // 4. To prevent any attempt at being hoodwinked by switching the // certificate between steps 2 and 3 the fingerprint of the certificate // presented in step 3 is compared against the one recorded in step 2. - $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options); + if (TlsHelper::isOpensslParseSafe()) { + $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options); - if ($certDetails) { - $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails; + if ($certDetails) { + $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails; - $this->retry = true; + $this->retry = true; + } + } else { + $this->io->writeError(sprintf( + 'Your version of PHP, %s, is affected by CVE-2013-6420 and cannot safely perform certificate validation, we strongly suggest you upgrade.', + PHP_VERSION + )); } } @@ -566,14 +573,24 @@ class RemoteFilesystem $tlsOptions['ssl']['CN_match'] = $host; $tlsOptions['ssl']['SNI_server_name'] = $host; - } - if (isset($this->peerCertificateMap[$this->getUrlAuthority($originUrl)])) { - // Handle subjectAltName on lesser PHP's. - $certMap = $this->peerCertificateMap[$this->getUrlAuthority($originUrl)]; + $urlAuthority = $this->getUrlAuthority($this->fileUrl); - $tlsOptions['ssl']['CN_match'] = $certMap['cn']; - $tlsOptions['ssl']['peer_fingerprint'] = $certMap['fp']; + if (isset($this->peerCertificateMap[$urlAuthority])) { + // Handle subjectAltName on lesser PHP's. + $certMap = $this->peerCertificateMap[$urlAuthority]; + + if ($this->io->isDebug()) { + $this->io->writeError(sprintf( + 'Using %s as CN for subjectAltName enabled host %s', + $certMap['cn'], + $urlAuthority + )); + } + + $tlsOptions['ssl']['CN_match'] = $certMap['cn']; + $tlsOptions['ssl']['peer_fingerprint'] = $certMap['fp']; + } } $headers = array(); @@ -849,8 +866,20 @@ class RemoteFilesystem unset($source, $target); } + /** + * Fetch certificate common name and fingerprint for validation of SAN. + * + * @todo Remove when PHP 5.6 is minimum supported version. + */ private function getCertificateCnAndFp($url, $options) { + if (PHP_VERSION_ID >= 50600) { + throw new \BadMethodCallException(sprintf( + '%s must not be used on PHP >= 5.6', + __METHOD__ + )); + } + $context = StreamContextFactory::getContext($url, $options, array('options' => array( 'ssl' => array( 'capture_peer_cert' => true, @@ -858,92 +887,30 @@ class RemoteFilesystem )) )); + // Ideally this would just use stream_socket_client() to avoid sending a + // HTTP request but that does not capture the certificate. if (false === $handle = @fopen($url, 'rb', false, $context)) { return; } // Close non authenticated connection without reading any content. fclose($handle); + $handle = null; $params = stream_context_get_params($context); if (!empty($params['options']['ssl']['peer_certificate'])) { $peerCertificate = $params['options']['ssl']['peer_certificate']; - $fp = $this->getCertificateFingerprint($peerCertificate); - $cert = openssl_x509_parse($peerCertificate, false); - $commonName = $cert['subject']['commonName']; - - $subjectAltName = preg_split('{\s*,\s*}', $cert['extensions']['subjectAltName']); - $subjectAltName = array_filter(array_map(function ($name) { - if (0 === strpos($name, 'DNS:')) { - return substr($name, 4); - } - }, $subjectAltName)); - - if (in_array(parse_url($url, PHP_URL_HOST), $subjectAltName, true)) { + if (TlsHelper::checkCertificateHost($peerCertificate, parse_url($url, PHP_URL_HOST), $commonName)) { return array( 'cn' => $commonName, - 'fp' => $fp, + 'fp' => TlsHelper::getCertificateFingerprint($peerCertificate), ); } - - // TODO: Support wildcards. } } - /** - * Get the certificate pin. - * - * By Kevin McArthur of StormTide Digital Studios Inc. - * @KevinSMcArthur / https://github.com/StormTide - * - * See http://tools.ietf.org/html/draft-ietf-websec-key-pinning-02 - * - * This method was adapted from Sslurp. - * https://github.com/EvanDotPro/Sslurp - * - * (c) Evan Coury - * - * For the full copyright and license information, please see below: - * - * Copyright (c) 2013, Evan Coury - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, - * are permitted provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - private function getCertificateFingerprint($certificate) - { - $pubkeydetails = openssl_pkey_get_details(openssl_get_publickey($certificate)); - $pubkeypem = $pubkeydetails['key']; - //Convert PEM to DER before SHA1'ing - $start = '-----BEGIN PUBLIC KEY-----'; - $end = '-----END PUBLIC KEY-----'; - $pemtrim = substr($pubkeypem, (strpos($pubkeypem, $start) + strlen($start)), (strlen($pubkeypem) - strpos($pubkeypem, $end)) * (-1)); - $der = base64_decode($pemtrim); - - return sha1($der); - } - private function getUrlAuthority($url) { $defaultPorts = array( @@ -952,9 +919,18 @@ class RemoteFilesystem 'https' => 443, ); - $defaultPort = $defaultPorts[parse_url($this->fileUrl, PHP_URL_SCHEME)]; - $port = parse_url($this->fileUrl, PHP_URL_PORT) ?: $defaultPort; + $scheme = parse_url($url, PHP_URL_SCHEME); - return parse_url($this->fileUrl, PHP_URL_HOST).':'.$port; + if (!isset($defaultPorts[$scheme])) { + throw new \InvalidArgumentException(sprintf( + 'Could not get default port for unknown scheme: %s', + $scheme + )); + } + + $defaultPort = $defaultPorts[$scheme]; + $port = parse_url($url, PHP_URL_PORT) ?: $defaultPort; + + return parse_url($url, PHP_URL_HOST).':'.$port; } } diff --git a/src/Composer/Util/TlsHelper.php b/src/Composer/Util/TlsHelper.php new file mode 100644 index 000000000..ce6738cdd --- /dev/null +++ b/src/Composer/Util/TlsHelper.php @@ -0,0 +1,289 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +use Symfony\Component\Process\PhpProcess; + +/** + * @author Chris Smith + */ +final class TlsHelper +{ + private static $useOpensslParse; + + /** + * Match hostname against a certificate. + * + * @param mixed $certificate X.509 certificate + * @param string $hostname Hostname in the URL + * @param string $cn Set to the common name of the certificate iff match found + * + * @return bool + */ + public static function checkCertificateHost($certificate, $hostname, &$cn = null) + { + $names = self::getCertificateNames($certificate); + + if (empty($names)) { + return false; + } + + $combinedNames = array_merge($names['san'], array($names['cn'])); + $hostname = strtolower($hostname); + + foreach ($combinedNames as $certName) { + $matcher = self::certNameMatcher($certName); + + if ($matcher && $matcher($hostname)) { + $cn = $names['cn']; + return true; + } + } + + return false; + } + + + /** + * Extract DNS names out of an X.509 certificate. + * + * @param mixed $certificate X.509 certificate + * + * @return array|null + */ + public static function getCertificateNames($certificate) + { + if (is_array($certificate)) { + $info = $certificate; + } elseif (self::isOpensslParseSafe()) { + $info = openssl_x509_parse($certificate, false); + } + + if (!isset($info['subject']['commonName'])) { + return; + } + + $commonName = strtolower($info['subject']['commonName']); + $subjectAltNames = array(); + + if (isset($info['extensions']['subjectAltName'])) { + $subjectAltNames = preg_split('{\s*,\s*}', $info['extensions']['subjectAltName']); + $subjectAltNames = array_filter(array_map(function ($name) { + if (0 === strpos($name, 'DNS:')) { + return strtolower(ltrim(substr($name, 4))); + } + }, $subjectAltNames)); + $subjectAltNames = array_values($subjectAltNames); + } + + return array( + 'cn' => $commonName, + 'san' => $subjectAltNames, + ); + } + + /** + * Get the certificate pin. + * + * By Kevin McArthur of StormTide Digital Studios Inc. + * @KevinSMcArthur / https://github.com/StormTide + * + * See http://tools.ietf.org/html/draft-ietf-websec-key-pinning-02 + * + * This method was adapted from Sslurp. + * https://github.com/EvanDotPro/Sslurp + * + * (c) Evan Coury + * + * For the full copyright and license information, please see below: + * + * Copyright (c) 2013, Evan Coury + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + public static function getCertificateFingerprint($certificate) + { + $pubkeydetails = openssl_pkey_get_details(openssl_get_publickey($certificate)); + $pubkeypem = $pubkeydetails['key']; + //Convert PEM to DER before SHA1'ing + $start = '-----BEGIN PUBLIC KEY-----'; + $end = '-----END PUBLIC KEY-----'; + $pemtrim = substr($pubkeypem, (strpos($pubkeypem, $start) + strlen($start)), (strlen($pubkeypem) - strpos($pubkeypem, $end)) * (-1)); + $der = base64_decode($pemtrim); + + return sha1($der); + } + + /** + * Test if it is safe to use the PHP function openssl_x509_parse(). + * + * This checks if OpenSSL extensions is vulnerable to remote code execution + * via the exploit documented as CVE-2013-6420. + * + * @return bool + */ + public static function isOpensslParseSafe() + { + if (null !== self::$useOpensslParse) { + return self::$useOpensslParse; + } + + if (PHP_VERSION_ID >= 50600) { + return self::$useOpensslParse = true; + } + + // Vulnerable: + // PHP 5.3.0 - PHP 5.3.27 + // PHP 5.4.0 - PHP 5.4.22 + // PHP 5.5.0 - PHP 5.5.6 + if ( + (PHP_VERSION_ID < 50400 && PHP_VERSION_ID >= 50328) + || (PHP_VERSION_ID < 50500 && PHP_VERSION_ID >= 50423) + || (PHP_VERSION_ID < 50600 && PHP_VERSION_ID >= 50507) + ) { + // This version of PHP has the fix for CVE-2013-6420 applied. + return self::$useOpensslParse = true; + } + + if ('\\' === DIRECTORY_SEPARATOR) { + // Windows is probably insecure in this case. + return self::$useOpensslParse = false; + } + + $compareDistroVersionPrefix = function ($prefix, $fixedVersion) { + $regex = '{^'.preg_quote($prefix).'([0-9]+)}$'; + + if (preg_match($regex, PHP_VERSION, $m)) { + return ((int) $m[1]) >= $fixedVersion; + } + + return false; + }; + + // Hard coded list of PHP distributions with the fix backported. + if ( + $compareDistroVersionPrefix('5.3.3-7+squeeze', 19) // Debian 6 (Squeeze) + || $compareDistroVersionPrefix('5.4.4-14+deb7u', 7) // Debian 7 (Wheezy) + || $compareDistroVersionPrefix('5.3.10-1ubuntu3.', 9) // Ubuntu 12.04 (Precise) + ) { + return self::$useOpensslParse = true; + } + + // This is where things get crazy, because distros backport security + // fixes the chances are on NIX systems the fix has been applied but + // it's not possible to verify that from the PHP version. + // + // To verify exec a new PHP process and run the issue testcase with + // known safe input that replicates the bug. + + // Based on testcase in https://github.com/php/php-src/commit/c1224573c773b6845e83505f717fbf820fc18415 + $cert = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVwRENDQTR5Z0F3SUJBZ0lKQUp6dThyNnU2ZUJjTUEwR0NTcUdTSWIzRFFFQkJRVUFNSUhETVFzd0NRWUQKVlFRR0V3SkVSVEVjTUJvR0ExVUVDQXdUVG05eVpISm9aV2x1TFZkbGMzUm1ZV3hsYmpFUU1BNEdBMVVFQnd3SApTOE9Ed3Jac2JqRVVNQklHQTFVRUNnd0xVMlZyZEdsdmJrVnBibk14SHpBZEJnTlZCQXNNRmsxaGJHbGphVzkxCmN5QkRaWEowSUZObFkzUnBiMjR4SVRBZkJnTlZCQU1NR0cxaGJHbGphVzkxY3k1elpXdDBhVzl1WldsdWN5NWsKWlRFcU1DZ0dDU3FHU0liM0RRRUpBUlliYzNSbFptRnVMbVZ6YzJWeVFITmxhM1JwYjI1bGFXNXpMbVJsTUhVWQpaREU1TnpBd01UQXhNREF3TURBd1dnQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBCkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEKQUFBQUFBQVhEVEUwTVRFeU9ERXhNemt6TlZvd2djTXhDekFKQmdOVkJBWVRBa1JGTVJ3d0dnWURWUVFJREJOTwpiM0prY21obGFXNHRWMlZ6ZEdaaGJHVnVNUkF3RGdZRFZRUUhEQWRMdzRQQ3RteHVNUlF3RWdZRFZRUUtEQXRUClpXdDBhVzl1UldsdWN6RWZNQjBHQTFVRUN3d1dUV0ZzYVdOcGIzVnpJRU5sY25RZ1UyVmpkR2x2YmpFaE1COEcKQTFVRUF3d1liV0ZzYVdOcGIzVnpMbk5sYTNScGIyNWxhVzV6TG1SbE1Tb3dLQVlKS29aSWh2Y05BUWtCRmh0egpkR1ZtWVc0dVpYTnpaWEpBYzJWcmRHbHZibVZwYm5NdVpHVXdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCCkR3QXdnZ0VLQW9JQkFRRERBZjNobDdKWTBYY0ZuaXlFSnBTU0RxbjBPcUJyNlFQNjV1c0pQUnQvOFBhRG9xQnUKd0VZVC9OYSs2ZnNnUGpDMHVLOURaZ1dnMnRIV1dvYW5TYmxBTW96NVBINlorUzRTSFJaN2UyZERJalBqZGhqaAowbUxnMlVNTzV5cDBWNzk3R2dzOWxOdDZKUmZIODFNTjJvYlhXczROdHp0TE11RDZlZ3FwcjhkRGJyMzRhT3M4CnBrZHVpNVVhd1Raa3N5NXBMUEhxNWNNaEZHbTA2djY1Q0xvMFYyUGQ5K0tBb2tQclBjTjVLTEtlYno3bUxwazYKU01lRVhPS1A0aWRFcXh5UTdPN2ZCdUhNZWRzUWh1K3ByWTNzaTNCVXlLZlF0UDVDWm5YMmJwMHdLSHhYMTJEWAoxbmZGSXQ5RGJHdkhUY3lPdU4rblpMUEJtM3ZXeG50eUlJdlZBZ01CQUFHalFqQkFNQWtHQTFVZEV3UUNNQUF3CkVRWUpZSVpJQVliNFFnRUJCQVFEQWdlQU1Bc0dBMVVkRHdRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU5CZ2txaGtpRzl3MEJBUVVGQUFPQ0FRRUFHMGZaWVlDVGJkajFYWWMrMVNub2FQUit2SThDOENhRAo4KzBVWWhkbnlVNGdnYTBCQWNEclk5ZTk0ZUVBdTZacXljRjZGakxxWFhkQWJvcHBXb2NyNlQ2R0QxeDMzQ2tsClZBcnpHL0t4UW9oR0QySmVxa2hJTWxEb214SE83a2EzOStPYThpMnZXTFZ5alU4QVp2V01BcnVIYTRFRU55RzcKbFcyQWFnYUZLRkNyOVRuWFRmcmR4R1ZFYnY3S1ZRNmJkaGc1cDVTanBXSDErTXEwM3VSM1pYUEJZZHlWODMxOQpvMGxWajFLRkkyRENML2xpV2lzSlJvb2YrMWNSMzVDdGQwd1lCY3BCNlRac2xNY09QbDc2ZHdLd0pnZUpvMlFnClpzZm1jMnZDMS9xT2xOdU5xLzBUenprVkd2OEVUVDNDZ2FVK1VYZTRYT1Z2a2NjZWJKbjJkZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'; + $script = <<<'EOT' + +error_reporting(-1); +$info = openssl_x509_parse(base64_decode('%s')); +var_dump(PHP_VERSION, $info['issuer']['emailAddress'], $info['validFrom_time_t']); + +EOT; + $script = '<'."?php\n".sprintf($script, $cert); + + try { + $process = new PhpProcess($script); + $process->mustRun(); + } catch (\Exception $e) { + // In the case of any exceptions just accept it is not possible to + // determine the safety of openssl_x509_parse and bail out. + return self::$useOpensslParse = false; + } + + $output = preg_split('{\r?\n}', trim($process->getOutput())); + $errorOutput = trim($process->getErrorOutput()); + + if ( + count($output) === 3 + && $output[0] === sprintf('string(%d) "%s"', strlen(PHP_VERSION), PHP_VERSION) + && $output[1] === 'string(27) "stefan.esser@sektioneins.de"' + && $output[2] === 'int(-1)' + && preg_match('{openssl_x509_parse\(\): illegal length in timestamp in - on line \d+}', $errorOutput) + ) { + // This PHP has the fix backported probably by a distro security team. + return self::$useOpensslParse = true; + } + + return self::$useOpensslParse = false; + } + + /** + * Convert certificate name into matching function. + * + * @param $certName CN/SAN + * + * @return callable|null + */ + private static function certNameMatcher($certName) + { + $wildcards = substr_count($certName, '*'); + + if (0 === $wildcards) { + // Literal match. + return function ($hostname) use ($certName) { + return $hostname === $certName; + }; + } + + if (1 === $wildcards) { + $components = explode('.', $certName); + + if (3 > count($components)) { + // Must have 3+ components + return; + } + + $firstComponent = $components[0]; + + // Wildcard must be the last character. + if ('*' !== $firstComponent[strlen($firstComponent) - 1]) { + return; + } + + $wildcardRegex = preg_quote($certName); + $wildcardRegex = str_replace('\\*', '[a-z0-9-]+', $wildcardRegex); + $wildcardRegex = "{^{$wildcardRegex}$}"; + + return function ($hostname) use ($wildcardRegex) { + // var_dump($wildcardRegex); + return 1 === preg_match($wildcardRegex, $hostname); + }; + } + } +} diff --git a/tests/Composer/Test/Util/TlsHelperTest.php b/tests/Composer/Test/Util/TlsHelperTest.php new file mode 100644 index 000000000..b17c42ba0 --- /dev/null +++ b/tests/Composer/Test/Util/TlsHelperTest.php @@ -0,0 +1,76 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Util; + +use Composer\Util\TlsHelper; + +class TlsHelperTest extends \PHPUnit_Framework_TestCase +{ + /** @dataProvider dataCheckCertificateHost */ + public function testCheckCertificateHost($expectedResult, $hostname, $certNames) + { + $certificate['subject']['commonName'] = $expectedCn = array_shift($certNames); + $certificate['extensions']['subjectAltName'] = $certNames ? 'DNS:'.implode(',DNS:', $certNames) : ''; + + $result = TlsHelper::checkCertificateHost($certificate, $hostname, $foundCn); + + if (true === $expectedResult) { + $this->assertTrue($result); + $this->assertSame($expectedCn, $foundCn); + } else { + $this->assertFalse($result); + $this->assertNull($foundCn); + } + } + + public function dataCheckCertificateHost() + { + return array( + array(true, 'getcomposer.org', array('getcomposer.org')), + array(true, 'getcomposer.org', array('getcomposer.org', 'packagist.org')), + array(true, 'getcomposer.org', array('packagist.org', 'getcomposer.org')), + array(true, 'foo.getcomposer.org', array('*.getcomposer.org')), + array(false, 'xyz.foo.getcomposer.org', array('*.getcomposer.org')), + array(true, 'foo.getcomposer.org', array('getcomposer.org', '*.getcomposer.org')), + array(true, 'foo.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')), + array(true, 'foo1.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')), + array(true, 'foo2.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')), + array(false, 'foo2.another.getcomposer.org', array('foo.getcomposer.org', 'foo*.getcomposer.org')), + array(false, 'test.example.net', array('**.example.net', '**.example.net')), + array(false, 'test.example.net', array('t*t.example.net', 't*t.example.net')), + array(false, 'xyz.example.org', array('*z.example.org', '*z.example.org')), + array(false, 'foo.bar.example.com', array('foo.*.example.com', 'foo.*.example.com')), + array(false, 'example.com', array('example.*', 'example.*')), + array(true, 'localhost', array('localhost')), + array(false, 'localhost', array('*')), + array(false, 'localhost', array('local*')), + array(false, 'example.net', array('*.net', '*.org', 'ex*.net')), + array(true, 'example.net', array('*.net', '*.org', 'example.net')), + ); + } + + public function testGetCertificateNames() + { + $certificate['subject']['commonName'] = 'example.net'; + $certificate['extensions']['subjectAltName'] = 'DNS: example.com, IP: 127.0.0.1, DNS: getcomposer.org, Junk: blah, DNS: composer.example.org'; + + $names = TlsHelper::getCertificateNames($certificate); + + $this->assertSame('example.net', $names['cn']); + $this->assertSame(array( + 'example.com', + 'getcomposer.org', + 'composer.example.org', + ), $names['san']); + } +} From 74aa73e841f57694907fa81b0f5094a4f5e738a9 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sun, 24 Jan 2016 19:09:35 +0000 Subject: [PATCH 3/6] The origin may not be the remote host --- src/Composer/Util/RemoteFilesystem.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index cee0670c0..afb942850 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -561,11 +561,7 @@ class RemoteFilesystem // Setup remaining TLS options - the matching may need monitoring, esp. www vs none in CN if ($this->disableTls === false && PHP_VERSION_ID < 50600) { - if (!preg_match('{^https?://}', $this->fileUrl)) { - $host = $originUrl; - } else { - $host = parse_url($this->fileUrl, PHP_URL_HOST); - } + $host = parse_url($this->fileUrl, PHP_URL_HOST); if ($host === 'github.com' || $host === 'api.github.com') { $host = '*.github.com'; From b32aad84394dbccef87c0aa114320a7d97873abc Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sun, 24 Jan 2016 19:10:11 +0000 Subject: [PATCH 4/6] Do not set TLS options on local URLs --- src/Composer/Util/RemoteFilesystem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index afb942850..09ab6188a 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -560,7 +560,7 @@ class RemoteFilesystem $tlsOptions = array(); // Setup remaining TLS options - the matching may need monitoring, esp. www vs none in CN - if ($this->disableTls === false && PHP_VERSION_ID < 50600) { + if ($this->disableTls === false && PHP_VERSION_ID < 50600 && !stream_is_local($this->fileUrl)) { $host = parse_url($this->fileUrl, PHP_URL_HOST); if ($host === 'github.com' || $host === 'api.github.com') { From bc8b7b0f78f68c0aceed500519e2492492828857 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sun, 24 Jan 2016 19:41:14 +0000 Subject: [PATCH 5/6] Remove left behind debug code --- src/Composer/Util/TlsHelper.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Composer/Util/TlsHelper.php b/src/Composer/Util/TlsHelper.php index ce6738cdd..cfa209e83 100644 --- a/src/Composer/Util/TlsHelper.php +++ b/src/Composer/Util/TlsHelper.php @@ -281,7 +281,6 @@ EOT; $wildcardRegex = "{^{$wildcardRegex}$}"; return function ($hostname) use ($wildcardRegex) { - // var_dump($wildcardRegex); return 1 === preg_match($wildcardRegex, $hostname); }; } From e2e07a32c3fb45c7b63fc33017904b496bac9a2a Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Sun, 24 Jan 2016 20:54:43 +0000 Subject: [PATCH 6/6] Fixes to vuln detection --- src/Composer/Util/TlsHelper.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Composer/Util/TlsHelper.php b/src/Composer/Util/TlsHelper.php index cfa209e83..4aea24df6 100644 --- a/src/Composer/Util/TlsHelper.php +++ b/src/Composer/Util/TlsHelper.php @@ -181,7 +181,7 @@ final class TlsHelper } $compareDistroVersionPrefix = function ($prefix, $fixedVersion) { - $regex = '{^'.preg_quote($prefix).'([0-9]+)}$'; + $regex = '{^'.preg_quote($prefix).'([0-9]+)$}'; if (preg_match($regex, PHP_VERSION, $m)) { return ((int) $m[1]) >= $fixedVersion; @@ -192,7 +192,7 @@ final class TlsHelper // Hard coded list of PHP distributions with the fix backported. if ( - $compareDistroVersionPrefix('5.3.3-7+squeeze', 19) // Debian 6 (Squeeze) + $compareDistroVersionPrefix('5.3.3-7+squeeze', 18) // Debian 6 (Squeeze) || $compareDistroVersionPrefix('5.4.4-14+deb7u', 7) // Debian 7 (Wheezy) || $compareDistroVersionPrefix('5.3.10-1ubuntu3.', 9) // Ubuntu 12.04 (Precise) ) { @@ -207,6 +207,7 @@ final class TlsHelper // known safe input that replicates the bug. // Based on testcase in https://github.com/php/php-src/commit/c1224573c773b6845e83505f717fbf820fc18415 + // changes in https://github.com/php/php-src/commit/76a7fd893b7d6101300cc656058704a73254d593 $cert = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVwRENDQTR5Z0F3SUJBZ0lKQUp6dThyNnU2ZUJjTUEwR0NTcUdTSWIzRFFFQkJRVUFNSUhETVFzd0NRWUQKVlFRR0V3SkVSVEVjTUJvR0ExVUVDQXdUVG05eVpISm9aV2x1TFZkbGMzUm1ZV3hsYmpFUU1BNEdBMVVFQnd3SApTOE9Ed3Jac2JqRVVNQklHQTFVRUNnd0xVMlZyZEdsdmJrVnBibk14SHpBZEJnTlZCQXNNRmsxaGJHbGphVzkxCmN5QkRaWEowSUZObFkzUnBiMjR4SVRBZkJnTlZCQU1NR0cxaGJHbGphVzkxY3k1elpXdDBhVzl1WldsdWN5NWsKWlRFcU1DZ0dDU3FHU0liM0RRRUpBUlliYzNSbFptRnVMbVZ6YzJWeVFITmxhM1JwYjI1bGFXNXpMbVJsTUhVWQpaREU1TnpBd01UQXhNREF3TURBd1dnQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBCkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEKQUFBQUFBQVhEVEUwTVRFeU9ERXhNemt6TlZvd2djTXhDekFKQmdOVkJBWVRBa1JGTVJ3d0dnWURWUVFJREJOTwpiM0prY21obGFXNHRWMlZ6ZEdaaGJHVnVNUkF3RGdZRFZRUUhEQWRMdzRQQ3RteHVNUlF3RWdZRFZRUUtEQXRUClpXdDBhVzl1UldsdWN6RWZNQjBHQTFVRUN3d1dUV0ZzYVdOcGIzVnpJRU5sY25RZ1UyVmpkR2x2YmpFaE1COEcKQTFVRUF3d1liV0ZzYVdOcGIzVnpMbk5sYTNScGIyNWxhVzV6TG1SbE1Tb3dLQVlKS29aSWh2Y05BUWtCRmh0egpkR1ZtWVc0dVpYTnpaWEpBYzJWcmRHbHZibVZwYm5NdVpHVXdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCCkR3QXdnZ0VLQW9JQkFRRERBZjNobDdKWTBYY0ZuaXlFSnBTU0RxbjBPcUJyNlFQNjV1c0pQUnQvOFBhRG9xQnUKd0VZVC9OYSs2ZnNnUGpDMHVLOURaZ1dnMnRIV1dvYW5TYmxBTW96NVBINlorUzRTSFJaN2UyZERJalBqZGhqaAowbUxnMlVNTzV5cDBWNzk3R2dzOWxOdDZKUmZIODFNTjJvYlhXczROdHp0TE11RDZlZ3FwcjhkRGJyMzRhT3M4CnBrZHVpNVVhd1Raa3N5NXBMUEhxNWNNaEZHbTA2djY1Q0xvMFYyUGQ5K0tBb2tQclBjTjVLTEtlYno3bUxwazYKU01lRVhPS1A0aWRFcXh5UTdPN2ZCdUhNZWRzUWh1K3ByWTNzaTNCVXlLZlF0UDVDWm5YMmJwMHdLSHhYMTJEWAoxbmZGSXQ5RGJHdkhUY3lPdU4rblpMUEJtM3ZXeG50eUlJdlZBZ01CQUFHalFqQkFNQWtHQTFVZEV3UUNNQUF3CkVRWUpZSVpJQVliNFFnRUJCQVFEQWdlQU1Bc0dBMVVkRHdRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU5CZ2txaGtpRzl3MEJBUVVGQUFPQ0FRRUFHMGZaWVlDVGJkajFYWWMrMVNub2FQUit2SThDOENhRAo4KzBVWWhkbnlVNGdnYTBCQWNEclk5ZTk0ZUVBdTZacXljRjZGakxxWFhkQWJvcHBXb2NyNlQ2R0QxeDMzQ2tsClZBcnpHL0t4UW9oR0QySmVxa2hJTWxEb214SE83a2EzOStPYThpMnZXTFZ5alU4QVp2V01BcnVIYTRFRU55RzcKbFcyQWFnYUZLRkNyOVRuWFRmcmR4R1ZFYnY3S1ZRNmJkaGc1cDVTanBXSDErTXEwM3VSM1pYUEJZZHlWODMxOQpvMGxWajFLRkkyRENML2xpV2lzSlJvb2YrMWNSMzVDdGQwd1lCY3BCNlRac2xNY09QbDc2ZHdLd0pnZUpvMlFnClpzZm1jMnZDMS9xT2xOdU5xLzBUenprVkd2OEVUVDNDZ2FVK1VYZTRYT1Z2a2NjZWJKbjJkZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'; $script = <<<'EOT' @@ -234,7 +235,7 @@ EOT; && $output[0] === sprintf('string(%d) "%s"', strlen(PHP_VERSION), PHP_VERSION) && $output[1] === 'string(27) "stefan.esser@sektioneins.de"' && $output[2] === 'int(-1)' - && preg_match('{openssl_x509_parse\(\): illegal length in timestamp in - on line \d+}', $errorOutput) + && preg_match('{openssl_x509_parse\(\): illegal (?:ASN1 data type for|length in) timestamp in - on line \d+}', $errorOutput) ) { // This PHP has the fix backported probably by a distro security team. return self::$useOpensslParse = true;