Last active
August 29, 2015 13:56
-
-
Save padraic/9237155 to your computer and use it in GitHub Desktop.
Composer RemoteFilesystem.php version showing missed peer_certificate (see tls-defaults branch on my fork)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
// when replaced for commit: https://github.com/padraic/composer/commit/2972ec3d8621acd9c415b7b1425586b90e7cea40 | |
/* | |
* This file is part of Composer. | |
* | |
* (c) Nils Adermann <[email protected]> | |
* Jordi Boggiano <[email protected]> | |
* | |
* For the full copyright and license information, please view the LICENSE | |
* file that was distributed with this source code. | |
*/ | |
namespace Composer\Util; | |
use Composer\Composer; | |
use Composer\IO\IOInterface; | |
use Composer\Downloader\TransportException; | |
/** | |
* @author François Pluchino <[email protected]> | |
* @author Jordi Boggiano <[email protected]> | |
* @author Nils Adermann <[email protected]> | |
*/ | |
class RemoteFilesystem | |
{ | |
private $io; | |
private $firstCall; | |
private $bytesMax; | |
private $originUrl; | |
private $fileUrl; | |
private $fileName; | |
private $retry; | |
private $progress; | |
private $lastProgress; | |
private $options; | |
private $disableTls = false; | |
private $retryTls = true; | |
/** | |
* Constructor. | |
* | |
* @param IOInterface $io The IO instance | |
* @param array $options The options | |
*/ | |
public function __construct(IOInterface $io, $options = array(), $disableTls = false) | |
{ | |
$this->io = $io; | |
/** | |
* Setup TLS options | |
* The cafile option can be set via config.json | |
*/ | |
if ($disableTls === false) { | |
$this->options = $this->getTlsDefaults(); | |
if (isset($options['ssl']['cafile']) | |
&& (!is_readable($options['ssl']['cafile']) | |
|| !\openssl_x509_parse(file_get_contents($options['ssl']['cafile'])))) { | |
throw new TransportException('The configured cafile was not valid or could not be read.'); | |
} | |
} else { | |
$this->disableTls = true; | |
} | |
// handle the other externally set options normally. | |
$this->options = array_replace_recursive($this->options, $options); | |
} | |
/** | |
* Copy the remote file in local. | |
* | |
* @param string $originUrl The origin URL | |
* @param string $fileUrl The file URL | |
* @param string $fileName the local filename | |
* @param boolean $progress Display the progression | |
* @param array $options Additional context options | |
* | |
* @return bool true | |
*/ | |
public function copy($originUrl, $fileUrl, $fileName, $progress = true, $options = array()) | |
{ | |
return $this->get($originUrl, $fileUrl, $options, $fileName, $progress); | |
} | |
/** | |
* Get the content. | |
* | |
* @param string $originUrl The origin URL | |
* @param string $fileUrl The file URL | |
* @param boolean $progress Display the progression | |
* @param array $options Additional context options | |
* | |
* @return string The content | |
*/ | |
public function getContents($originUrl, $fileUrl, $progress = true, $options = array()) | |
{ | |
return $this->get($originUrl, $fileUrl, $options, null, $progress); | |
} | |
/** | |
* Retrieve the options set in the constructor | |
* | |
* @return array Options | |
*/ | |
public function getOptions() | |
{ | |
return $this->options; | |
} | |
/** | |
* Get file content or copy action. | |
* | |
* @param string $originUrl The origin URL | |
* @param string $fileUrl The file URL | |
* @param array $additionalOptions context options | |
* @param string $fileName the local filename | |
* @param boolean $progress Display the progression | |
* | |
* @throws TransportException|\Exception | |
* @throws TransportException When the file could not be downloaded | |
* | |
* @return bool|string | |
*/ | |
protected function get($originUrl, $fileUrl, $additionalOptions = array(), $fileName = null, $progress = true, $expectedCommonName = '') | |
{ | |
$this->bytesMax = 0; | |
$this->originUrl = $originUrl; | |
$this->fileUrl = $fileUrl; | |
$this->fileName = $fileName; | |
$this->progress = $progress; | |
$this->lastProgress = null; | |
// capture username/password from URL if there is one | |
if (preg_match('{^https?://(.+):(.+)@([^/]+)}i', $fileUrl, $match)) { | |
$this->io->setAuthentication($originUrl, urldecode($match[1]), urldecode($match[2])); | |
} | |
$options = $this->getOptionsForUrl($originUrl, $additionalOptions, $expectedCommonName); | |
if ($this->io->isDebug()) { | |
$this->io->write((substr($fileUrl, 0, 4) === 'http' ? 'Downloading ' : 'Reading ') . $fileUrl); | |
} | |
if (isset($options['github-token'])) { | |
$fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['github-token']; | |
unset($options['github-token']); | |
} | |
$ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet'))); | |
if ($this->progress) { | |
$this->io->write(" Downloading: <comment>connection...</comment>", false); | |
} | |
$errorMessage = ''; | |
$errorCode = 0; | |
$result = false; | |
set_error_handler(function ($code, $msg) use (&$errorMessage) { | |
if ($errorMessage) { | |
$errorMessage .= "\n"; | |
} | |
$errorMessage .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg); | |
}); | |
try { | |
$result = file_get_contents($fileUrl, false, $ctx); | |
} catch (\Exception $e) { | |
if ($e instanceof TransportException && !empty($http_response_header[0])) { | |
$e->setHeaders($http_response_header); | |
} | |
} | |
if ($errorMessage && !ini_get('allow_url_fopen')) { | |
$errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')'; | |
} | |
restore_error_handler(); | |
if (isset($e) && !$this->retry) { | |
throw $e; | |
} | |
// fix for 5.4.0 https://bugs.php.net/bug.php?id=61336 | |
if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ ([45]\d\d)}i', $http_response_header[0], $match)) { | |
$result = false; | |
$errorCode = $match[1]; | |
} | |
// decode gzip | |
if ($result && extension_loaded('zlib') && substr($fileUrl, 0, 4) === 'http') { | |
$decode = false; | |
foreach ($http_response_header as $header) { | |
if (preg_match('{^content-encoding: *gzip *$}i', $header)) { | |
$decode = true; | |
continue; | |
} elseif (preg_match('{^HTTP/}i', $header)) { | |
$decode = false; | |
} | |
} | |
if ($decode) { | |
if (version_compare(PHP_VERSION, '5.4.0', '>=')) { | |
$result = zlib_decode($result); | |
} else { | |
// work around issue with gzuncompress & co that do not work with all gzip checksums | |
$result = file_get_contents('compress.zlib://data:application/octet-stream;base64,'.base64_encode($result)); | |
} | |
} | |
} | |
if ($this->progress) { | |
$this->io->overwrite(" Downloading: <comment>100%</comment>"); | |
} | |
// handle copy command if download was successful | |
if (false !== $result && null !== $fileName) { | |
if ('' === $result) { | |
throw new TransportException('"'.$this->fileUrl.'" appears broken, and returned an empty 200 response'); | |
} | |
$errorMessage = ''; | |
set_error_handler(function ($code, $msg) use (&$errorMessage) { | |
if ($errorMessage) { | |
$errorMessage .= "\n"; | |
} | |
$errorMessage .= preg_replace('{^file_put_contents\(.*?\): }', '', $msg); | |
}); | |
$result = (bool) file_put_contents($fileName, $result); | |
restore_error_handler(); | |
if (false === $result) { | |
throw new TransportException('The "'.$this->fileUrl.'" file could not be written to '.$fileName.': '.$errorMessage); | |
} | |
} | |
/** | |
* 1. Check if failure is TLS related | |
* 2. Check if we got the peer certificate (should do - it's set explicitly) | |
* 3. Get the expected CN from the cert | |
* 4. Get the SAN values (if present) | |
* 5. Check if originUrl (or determined host) is on the SAN list | |
* 6. If yes, retry TLS connection with expected CN. If no, let the request fail as normal. | |
* 7. Remember that you didn't write the C code for PHP streams... | |
*/ | |
if (preg_match("|did not match expected CN|i", $errorMessage)) { | |
$meta = stream_context_get_params($ctx); | |
//$cert = \openssl_x509_parse($meta["options"]["ssl"]["peer_certificate"]); | |
var_dump(isset($meta["options"]["ssl"]["peer_certificate"]), $originUrl, $this->fileUrl, $this->options, $meta['options'],$expectedCommonName); exit; | |
} | |
/**$meta = stream_context_get_params($ctx); | |
if (isset($meta["options"]["ssl"]) | |
&& array_key_exists('peer_certificate', $meta["options"]["ssl"]) | |
&& is_resource($meta["options"]["ssl"]["peer_certificate"])) { | |
$cert = \openssl_x509_parse($meta["options"]["ssl"]["peer_certificate"]); | |
if (false === $result) { | |
var_dump($cert); exit; | |
} | |
if (false === $result | |
&& preg_match("|did not match expected CN|i", $errorMessage) | |
&& isset($cert['extensions']['subjectAltName']) | |
&& $this->retryTls === true) { | |
$this->retryTls = false; | |
$expectedCommonName = $cert['subject']['CN']; | |
if (!preg_match("|^https?://|", $originUrl)) { | |
$host = $originUrl; | |
} else { | |
$host = parse_url($originUrl, PHP_URL_HOST); | |
} | |
preg_match_all("|DNS:([[:alnum:]\.\*]*)|", $cert['extensions']['subjectAltName'], $matches); | |
if (in_array($host, $matches[1])) { | |
$this->io->write(" <warning>Retrying download from ".$originUrl." with SSL Cert Common Name (CN): ".$expectedCommonName."</warning>"); | |
return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress, $expectedCommonName); | |
} | |
} | |
}**/ | |
if ($this->retry) { | |
$this->retry = false; | |
return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress, $expectedCommonName); | |
} | |
if (false === $result) { | |
$e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded: '.$errorMessage.' using CN='.$expectedCommonName, $errorCode); | |
if (!empty($http_response_header[0])) { | |
$e->setHeaders($http_response_header); | |
} | |
throw $e; | |
} | |
return $result; | |
} | |
/** | |
* Get notification action. | |
* | |
* @param integer $notificationCode The notification code | |
* @param integer $severity The severity level | |
* @param string $message The message | |
* @param integer $messageCode The message code | |
* @param integer $bytesTransferred The loaded size | |
* @param integer $bytesMax The total size | |
* @throws TransportException | |
*/ | |
protected function callbackGet($notificationCode, $severity, $message, $messageCode, $bytesTransferred, $bytesMax) | |
{ | |
switch ($notificationCode) { | |
case STREAM_NOTIFY_FAILURE: | |
case STREAM_NOTIFY_AUTH_REQUIRED: | |
if (401 === $messageCode) { | |
if (!$this->io->isInteractive()) { | |
$message = "The '" . $this->fileUrl . "' URL required authentication.\nYou must be using the interactive console"; | |
throw new TransportException($message, 401); | |
} | |
$this->promptAuthAndRetry(); | |
break; | |
} | |
if ($notificationCode === STREAM_NOTIFY_AUTH_REQUIRED) { | |
break; | |
} | |
throw new TransportException('The "'.$this->fileUrl.'" file could not be downloaded ('.trim($message).')', $messageCode); | |
case STREAM_NOTIFY_AUTH_RESULT: | |
if (403 === $messageCode) { | |
if (!$this->io->isInteractive() || $this->io->hasAuthentication($this->originUrl)) { | |
$message = "The '" . $this->fileUrl . "' URL could not be accessed: " . $message; | |
throw new TransportException($message, 403); | |
} | |
$this->promptAuthAndRetry(); | |
break; | |
} | |
break; | |
case STREAM_NOTIFY_FILE_SIZE_IS: | |
if ($this->bytesMax < $bytesMax) { | |
$this->bytesMax = $bytesMax; | |
} | |
break; | |
case STREAM_NOTIFY_PROGRESS: | |
if ($this->bytesMax > 0 && $this->progress) { | |
$progression = 0; | |
if ($this->bytesMax > 0) { | |
$progression = round($bytesTransferred / $this->bytesMax * 100); | |
} | |
if ((0 === $progression % 5) && $progression !== $this->lastProgress) { | |
$this->lastProgress = $progression; | |
$this->io->overwrite(" Downloading: <comment>$progression%</comment>", false); | |
} | |
} | |
break; | |
default: | |
break; | |
} | |
} | |
protected function promptAuthAndRetry() | |
{ | |
$this->io->overwrite(' Authentication required (<info>'.parse_url($this->fileUrl, PHP_URL_HOST).'</info>):'); | |
$username = $this->io->ask(' Username: '); | |
$password = $this->io->askAndHideAnswer(' Password: '); | |
$this->io->setAuthentication($this->originUrl, $username, $password); | |
$this->retry = true; | |
throw new TransportException('RETRY'); | |
} | |
protected function getOptionsForUrl($originUrl, $additionalOptions, $validCommonName = '') | |
{ | |
// Setup remaining TLS options - the matching may need monitoring, esp. www vs none in CN | |
if ($this->disableTls === false) { | |
if (!preg_match("|^https?://|", $originUrl)) { | |
$host = $originUrl; | |
} else { | |
$host = parse_url($originUrl, PHP_URL_HOST); | |
} | |
/** | |
* This is sheer painful, but hopefully it'll be a footnote once SAN support | |
* reaches PHP 5.4 and 5.5... | |
* Side-effect: We're betting on the CN being either a wildcard or www, e.g. *.github.com or www.example.com. | |
* TODO: Consider something more explicitly user based. | |
*/ | |
if (strlen($validCommonName) > 0) { | |
$host = $validCommonName; | |
} | |
$this->options['ssl']['CN_match'] = $host; | |
$this->options['ssl']['SNI_server_name'] = $host; | |
} | |
$headers = array( | |
sprintf( | |
'User-Agent: Composer/%s (%s; %s; PHP %s.%s.%s)', | |
Composer::VERSION === '@package_version@' ? 'source' : Composer::VERSION, | |
php_uname('s'), | |
php_uname('r'), | |
PHP_MAJOR_VERSION, | |
PHP_MINOR_VERSION, | |
PHP_RELEASE_VERSION | |
) | |
); | |
if (extension_loaded('zlib')) { | |
$headers[] = 'Accept-Encoding: gzip'; | |
} | |
$options = array_replace_recursive($this->options, $additionalOptions); | |
if ($this->io->hasAuthentication($originUrl)) { | |
$auth = $this->io->getAuthentication($originUrl); | |
if ('github.com' === $originUrl && 'x-oauth-basic' === $auth['password']) { | |
$options['github-token'] = $auth['username']; | |
} else { | |
$authStr = base64_encode($auth['username'] . ':' . $auth['password']); | |
$headers[] = 'Authorization: Basic '.$authStr; | |
} | |
} | |
if (isset($options['http']['header']) && !is_array($options['http']['header'])) { | |
$options['http']['header'] = explode("\r\n", trim($options['http']['header'], "\r\n")); | |
} | |
foreach ($headers as $header) { | |
$options['http']['header'][] = $header; | |
} | |
return $options; | |
} | |
protected function getTlsDefaults() | |
{ | |
$ciphers = implode(':', array( | |
'ECDHE-RSA-AES128-GCM-SHA256', | |
'ECDHE-ECDSA-AES128-GCM-SHA256', | |
'ECDHE-RSA-AES256-GCM-SHA384', | |
'ECDHE-ECDSA-AES256-GCM-SHA384', | |
'DHE-RSA-AES128-GCM-SHA256', | |
'DHE-DSS-AES128-GCM-SHA256', | |
'kEDH+AESGCM', | |
'ECDHE-RSA-AES128-SHA256', | |
'ECDHE-ECDSA-AES128-SHA256', | |
'ECDHE-RSA-AES128-SHA', | |
'ECDHE-ECDSA-AES128-SHA', | |
'ECDHE-RSA-AES256-SHA384', | |
'ECDHE-ECDSA-AES256-SHA384', | |
'ECDHE-RSA-AES256-SHA', | |
'ECDHE-ECDSA-AES256-SHA', | |
'DHE-RSA-AES128-SHA256', | |
'DHE-RSA-AES128-SHA', | |
'DHE-DSS-AES128-SHA256', | |
'DHE-RSA-AES256-SHA256', | |
'DHE-DSS-AES256-SHA', | |
'DHE-RSA-AES256-SHA', | |
'AES128-GCM-SHA256', | |
'AES256-GCM-SHA384', | |
'ECDHE-RSA-RC4-SHA', | |
'ECDHE-ECDSA-RC4-SHA', | |
'AES128', | |
'AES256', | |
'RC4-SHA', | |
'HIGH', | |
'!aNULL', | |
'!eNULL', | |
'!EXPORT', | |
'!DES', | |
'!3DES', | |
'!MD5', | |
'!PSK' | |
)); | |
/** | |
* CN_match and SNI_server_name are only known once a URL is passed. | |
* They will be set in the getOptionsForUrl() method which receives a URL. | |
* | |
* cafile or capath can be overridden by passing in those options to constructor. | |
*/ | |
$options = array( | |
'ssl' => array( | |
'ciphers' => $ciphers, | |
'verify_peer' => true, | |
'verify_depth' => 7, | |
'SNI_enabled' => true, | |
'capture_peer_cert' => true, | |
) | |
); | |
/** | |
* Attempt to find a local cafile or throw an exception. | |
* The user may go download one if this occurs. | |
*/ | |
$result = $this->getSystemCaRootBundlePath(); | |
if ($result) { | |
$options['ssl']['cafile'] = $result; | |
} else { | |
throw new TransportException('A valid cafile could not be located automatically.'); | |
} | |
/** | |
* Disable TLS compression to prevent CRIME attacks where supported. | |
*/ | |
if (version_compare(PHP_VERSION, '5.4.13') >= 0) { | |
$options['ssl']['disable_compression'] = true; | |
} | |
return $options; | |
} | |
/** | |
* This method was adapted from Sslurp. | |
* https://github.com/EvanDotPro/Sslurp | |
* | |
* (c) Evan Coury <[email protected]> | |
* | |
* 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. | |
*/ | |
protected static function getSystemCaRootBundlePath() | |
{ | |
if (isset($found)) { | |
return $found; | |
} | |
// If SSL_CERT_FILE env variable points to a valid certificate/bundle, use that. | |
// This mimics how OpenSSL uses the SSL_CERT_FILE env variable. | |
$envCertFile = getenv('SSL_CERT_FILE'); | |
if ($envCertFile && is_readable($envCertFile) && \openssl_x509_parse(file_get_contents($envCertFile))) { | |
// Possibly throw exception instead of ignoring SSL_CERT_FILE if it's invalid? | |
return $envCertFile; | |
} | |
$caBundlePaths = array( | |
'/etc/pki/tls/certs/ca-bundle.crt', // Fedora, RHEL, CentOS (ca-certificates package) | |
'/etc/ssl/certs/ca-certificates.crt', // Debian, Ubuntu, Gentoo, Arch Linux (ca-certificates package) | |
'/etc/ssl/ca-bundle.pem', // SUSE, openSUSE (ca-certificates package) | |
'/usr/local/share/certs/ca-root-nss.crt', // FreeBSD (ca_root_nss_package) | |
'/usr/ssl/certs/ca-bundle.crt', // Cygwin | |
'/opt/local/share/curl/curl-ca-bundle.crt', // OS X macports, curl-ca-bundle package | |
'/usr/local/share/curl/curl-ca-bundle.crt', // Default cURL CA bunde path (without --with-ca-bundle option) | |
'/usr/share/ssl/certs/ca-bundle.crt', // Really old RedHat? | |
); | |
static $found = false; | |
foreach ($caBundlePaths as $caBundle) { | |
if (is_readable($caBundle) && \openssl_x509_parse(file_get_contents($caBundle))) { | |
$found = true; | |
break; | |
} | |
} | |
if ($found) { | |
$found = $caBundle; | |
} | |
return $found; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment