diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 81f18fa19..9fa54590e 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; @@ -264,6 +265,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 = TlsHelper::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); @@ -370,6 +383,39 @@ 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. + if (TlsHelper::isOpensslParseSafe()) { + $certDetails = $this->getCertificateCnAndFp($this->fileUrl, $options); + + if ($certDetails) { + $this->peerCertificateMap[$this->getUrlAuthority($this->fileUrl)] = $certDetails; + + $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 + )); + } + } + if ($this->retry) { $this->retry = false; @@ -531,12 +577,8 @@ 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 (!preg_match('{^https?://}', $this->fileUrl)) { - $host = $originUrl; - } else { - $host = parse_url($this->fileUrl, PHP_URL_HOST); - } + if ($this->disableTls === false && PHP_VERSION_ID < 50600 && !stream_is_local($this->fileUrl)) { + $host = parse_url($this->fileUrl, PHP_URL_HOST); if (PHP_VERSION_ID >= 50304) { // Must manually follow when setting CN_match because this causes all @@ -555,6 +597,24 @@ class RemoteFilesystem $tlsOptions['ssl']['CN_match'] = $host; $tlsOptions['ssl']['SNI_server_name'] = $host; + + $urlAuthority = $this->getUrlAuthority($this->fileUrl); + + 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(); @@ -702,6 +762,7 @@ class RemoteFilesystem 'verify_peer' => true, 'verify_depth' => 7, 'SNI_enabled' => true, + 'capture_peer_cert' => true, ) ); @@ -883,4 +944,72 @@ 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, + 'verify_peer' => false, // Yes this is fucking insane! But PHP is lame. + )) + )); + + // 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']; + + if (TlsHelper::checkCertificateHost($peerCertificate, parse_url($url, PHP_URL_HOST), $commonName)) { + return array( + 'cn' => $commonName, + 'fp' => TlsHelper::getCertificateFingerprint($peerCertificate), + ); + } + } + } + + private function getUrlAuthority($url) + { + $defaultPorts = array( + 'ftp' => 21, + 'http' => 80, + 'https' => 443, + ); + + $scheme = parse_url($url, PHP_URL_SCHEME); + + 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..4aea24df6 --- /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', 18) // 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 + // changes in https://github.com/php/php-src/commit/76a7fd893b7d6101300cc656058704a73254d593 + $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 (?: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; + } + + 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) { + 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']); + } +}