From fc5002015b2a9e16a3c6b9992d55b45c73a8d2fb Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Sun, 8 Nov 2009 23:28:51 +0100 Subject: Revert "* [Cc]an't -> [Cc]annot" This reverts commit 0ab17f382b9993ada3d12d4cdace72cca53fb545. --- extlib/HTTP/Request2/Adapter/Socket.php | 1940 +++++++++++++++---------------- 1 file changed, 970 insertions(+), 970 deletions(-) (limited to 'extlib/HTTP/Request2') diff --git a/extlib/HTTP/Request2/Adapter/Socket.php b/extlib/HTTP/Request2/Adapter/Socket.php index 13cd6136f..ff44d4959 100644 --- a/extlib/HTTP/Request2/Adapter/Socket.php +++ b/extlib/HTTP/Request2/Adapter/Socket.php @@ -1,971 +1,971 @@ - - * 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. - * * The names of the authors may not be used to endorse or promote products - * derived from this software without specific prior written permission. - * - * 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. - * - * @category HTTP - * @package HTTP_Request2 - * @author Alexey Borzov - * @license http://opensource.org/licenses/bsd-license.php New BSD License - * @version CVS: $Id: Socket.php 279760 2009-05-03 10:46:42Z avb $ - * @link http://pear.php.net/package/HTTP_Request2 - */ - -/** - * Base class for HTTP_Request2 adapters - */ -require_once 'HTTP/Request2/Adapter.php'; - -/** - * Socket-based adapter for HTTP_Request2 - * - * This adapter uses only PHP sockets and will work on almost any PHP - * environment. Code is based on original HTTP_Request PEAR package. - * - * @category HTTP - * @package HTTP_Request2 - * @author Alexey Borzov - * @version Release: 0.4.1 - */ -class HTTP_Request2_Adapter_Socket extends HTTP_Request2_Adapter -{ - /** - * Regular expression for 'token' rule from RFC 2616 - */ - const REGEXP_TOKEN = '[^\x00-\x1f\x7f-\xff()<>@,;:\\\\"/\[\]?={}\s]+'; - - /** - * Regular expression for 'quoted-string' rule from RFC 2616 - */ - const REGEXP_QUOTED_STRING = '"(?:\\\\.|[^\\\\"])*"'; - - /** - * Connected sockets, needed for Keep-Alive support - * @var array - * @see connect() - */ - protected static $sockets = array(); - - /** - * Data for digest authentication scheme - * - * The keys for the array are URL prefixes. - * - * The values are associative arrays with data (realm, nonce, nonce-count, - * opaque...) needed for digest authentication. Stored here to prevent making - * duplicate requests to digest-protected resources after we have already - * received the challenge. - * - * @var array - */ - protected static $challenges = array(); - - /** - * Connected socket - * @var resource - * @see connect() - */ - protected $socket; - - /** - * Challenge used for server digest authentication - * @var array - */ - protected $serverChallenge; - - /** - * Challenge used for proxy digest authentication - * @var array - */ - protected $proxyChallenge; - - /** - * Global timeout, exception will be raised if request continues past this time - * @var integer - */ - protected $timeout = null; - - /** - * Remaining length of the current chunk, when reading chunked response - * @var integer - * @see readChunked() - */ - protected $chunkLength = 0; - - /** - * Sends request to the remote server and returns its response - * - * @param HTTP_Request2 - * @return HTTP_Request2_Response - * @throws HTTP_Request2_Exception - */ - public function sendRequest(HTTP_Request2 $request) - { - $this->request = $request; - $keepAlive = $this->connect(); - $headers = $this->prepareHeaders(); - - // Use global request timeout if given, see feature requests #5735, #8964 - if ($timeout = $request->getConfig('timeout')) { - $this->timeout = time() + $timeout; - } else { - $this->timeout = null; - } - - try { - if (false === @fwrite($this->socket, $headers, strlen($headers))) { - throw new HTTP_Request2_Exception('Error writing request'); - } - // provide request headers to the observer, see request #7633 - $this->request->setLastEvent('sentHeaders', $headers); - $this->writeBody(); - - if ($this->timeout && time() > $this->timeout) { - throw new HTTP_Request2_Exception( - 'Request timed out after ' . - $request->getConfig('timeout') . ' second(s)' - ); - } - - $response = $this->readResponse(); - - if (!$this->canKeepAlive($keepAlive, $response)) { - $this->disconnect(); - } - - if ($this->shouldUseProxyDigestAuth($response)) { - return $this->sendRequest($request); - } - if ($this->shouldUseServerDigestAuth($response)) { - return $this->sendRequest($request); - } - if ($authInfo = $response->getHeader('authentication-info')) { - $this->updateChallenge($this->serverChallenge, $authInfo); - } - if ($proxyInfo = $response->getHeader('proxy-authentication-info')) { - $this->updateChallenge($this->proxyChallenge, $proxyInfo); - } - - } catch (Exception $e) { - $this->disconnect(); - throw $e; - } - - return $response; - } - - /** - * Connects to the remote server - * - * @return bool whether the connection can be persistent - * @throws HTTP_Request2_Exception - */ - protected function connect() - { - $secure = 0 == strcasecmp($this->request->getUrl()->getScheme(), 'https'); - $tunnel = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod(); - $headers = $this->request->getHeaders(); - $reqHost = $this->request->getUrl()->getHost(); - if (!($reqPort = $this->request->getUrl()->getPort())) { - $reqPort = $secure? 443: 80; - } - - if ($host = $this->request->getConfig('proxy_host')) { - if (!($port = $this->request->getConfig('proxy_port'))) { - throw new HTTP_Request2_Exception('Proxy port not provided'); - } - $proxy = true; - } else { - $host = $reqHost; - $port = $reqPort; - $proxy = false; - } - - if ($tunnel && !$proxy) { - throw new HTTP_Request2_Exception( - "Trying to perform CONNECT request without proxy" - ); - } - if ($secure && !in_array('ssl', stream_get_transports())) { - throw new HTTP_Request2_Exception( - 'Need OpenSSL support for https:// requests' - ); - } - - // RFC 2068, section 19.7.1: A client MUST NOT send the Keep-Alive - // connection token to a proxy server... - if ($proxy && !$secure && - !empty($headers['connection']) && 'Keep-Alive' == $headers['connection'] - ) { - $this->request->setHeader('connection'); - } - - $keepAlive = ('1.1' == $this->request->getConfig('protocol_version') && - empty($headers['connection'])) || - (!empty($headers['connection']) && - 'Keep-Alive' == $headers['connection']); - $host = ((!$secure || $proxy)? 'tcp://': 'ssl://') . $host; - - $options = array(); - if ($secure || $tunnel) { - foreach ($this->request->getConfig() as $name => $value) { - if ('ssl_' == substr($name, 0, 4) && null !== $value) { - if ('ssl_verify_host' == $name) { - if ($value) { - $options['CN_match'] = $reqHost; - } - } else { - $options[substr($name, 4)] = $value; - } - } - } - ksort($options); - } - - // Changing SSL context options after connection is established does *not* - // work, we need a new connection if options change - $remote = $host . ':' . $port; - $socketKey = $remote . (($secure && $proxy)? "->{$reqHost}:{$reqPort}": '') . - (empty($options)? '': ':' . serialize($options)); - unset($this->socket); - - // We use persistent connections and have a connected socket? - // Ensure that the socket is still connected, see bug #16149 - if ($keepAlive && !empty(self::$sockets[$socketKey]) && - !feof(self::$sockets[$socketKey]) - ) { - $this->socket =& self::$sockets[$socketKey]; - - } elseif ($secure && $proxy && !$tunnel) { - $this->establishTunnel(); - $this->request->setLastEvent( - 'connect', "ssl://{$reqHost}:{$reqPort} via {$host}:{$port}" - ); - self::$sockets[$socketKey] =& $this->socket; - - } else { - // Set SSL context options if doing HTTPS request or creating a tunnel - $context = stream_context_create(); - foreach ($options as $name => $value) { - if (!stream_context_set_option($context, 'ssl', $name, $value)) { - throw new HTTP_Request2_Exception( - "Error setting SSL context option '{$name}'" - ); - } - } - $this->socket = @stream_socket_client( - $remote, $errno, $errstr, - $this->request->getConfig('connect_timeout'), - STREAM_CLIENT_CONNECT, $context - ); - if (!$this->socket) { - throw new HTTP_Request2_Exception( - "Unable to connect to {$remote}. Error #{$errno}: {$errstr}" - ); - } - $this->request->setLastEvent('connect', $remote); - self::$sockets[$socketKey] =& $this->socket; - } - return $keepAlive; - } - - /** - * Establishes a tunnel to a secure remote server via HTTP CONNECT request - * - * This method will fail if 'ssl_verify_peer' is enabled. Probably because PHP - * sees that we are connected to a proxy server (duh!) rather than the server - * that presents its certificate. - * - * @link http://tools.ietf.org/html/rfc2817#section-5.2 - * @throws HTTP_Request2_Exception - */ - protected function establishTunnel() - { - $donor = new self; - $connect = new HTTP_Request2( - $this->request->getUrl(), HTTP_Request2::METHOD_CONNECT, - array_merge($this->request->getConfig(), - array('adapter' => $donor)) - ); - $response = $connect->send(); - // Need any successful (2XX) response - if (200 > $response->getStatus() || 300 <= $response->getStatus()) { - throw new HTTP_Request2_Exception( - 'Failed to connect via HTTPS proxy. Proxy response: ' . - $response->getStatus() . ' ' . $response->getReasonPhrase() - ); - } - $this->socket = $donor->socket; - - $modes = array( - STREAM_CRYPTO_METHOD_TLS_CLIENT, - STREAM_CRYPTO_METHOD_SSLv3_CLIENT, - STREAM_CRYPTO_METHOD_SSLv23_CLIENT, - STREAM_CRYPTO_METHOD_SSLv2_CLIENT - ); - - foreach ($modes as $mode) { - if (stream_socket_enable_crypto($this->socket, true, $mode)) { - return; - } - } - throw new HTTP_Request2_Exception( - 'Failed to enable secure connection when connecting through proxy' - ); - } - - /** - * Checks whether current connection may be reused or should be closed - * - * @param boolean whether connection could be persistent - * in the first place - * @param HTTP_Request2_Response response object to check - * @return boolean - */ - protected function canKeepAlive($requestKeepAlive, HTTP_Request2_Response $response) - { - // Do not close socket on successful CONNECT request - if (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() && - 200 <= $response->getStatus() && 300 > $response->getStatus() - ) { - return true; - } - - $lengthKnown = 'chunked' == strtolower($response->getHeader('transfer-encoding')) || - null !== $response->getHeader('content-length'); - $persistent = 'keep-alive' == strtolower($response->getHeader('connection')) || - (null === $response->getHeader('connection') && - '1.1' == $response->getVersion()); - return $requestKeepAlive && $lengthKnown && $persistent; - } - - /** - * Disconnects from the remote server - */ - protected function disconnect() - { - if (is_resource($this->socket)) { - fclose($this->socket); - $this->socket = null; - $this->request->setLastEvent('disconnect'); - } - } - - /** - * Checks whether another request should be performed with server digest auth - * - * Several conditions should be satisfied for it to return true: - * - response status should be 401 - * - auth credentials should be set in the request object - * - response should contain WWW-Authenticate header with digest challenge - * - there is either no challenge stored for this URL or new challenge - * contains stale=true parameter (in other case we probably just failed - * due to invalid username / password) - * - * The method stores challenge values in $challenges static property - * - * @param HTTP_Request2_Response response to check - * @return boolean whether another request should be performed - * @throws HTTP_Request2_Exception in case of unsupported challenge parameters - */ - protected function shouldUseServerDigestAuth(HTTP_Request2_Response $response) - { - // no sense repeating a request if we don't have credentials - if (401 != $response->getStatus() || !$this->request->getAuth()) { - return false; - } - if (!$challenge = $this->parseDigestChallenge($response->getHeader('www-authenticate'))) { - return false; - } - - $url = $this->request->getUrl(); - $scheme = $url->getScheme(); - $host = $scheme . '://' . $url->getHost(); - if ($port = $url->getPort()) { - if ((0 == strcasecmp($scheme, 'http') && 80 != $port) || - (0 == strcasecmp($scheme, 'https') && 443 != $port) - ) { - $host .= ':' . $port; - } - } - - if (!empty($challenge['domain'])) { - $prefixes = array(); - foreach (preg_split('/\\s+/', $challenge['domain']) as $prefix) { - // don't bother with different servers - if ('/' == substr($prefix, 0, 1)) { - $prefixes[] = $host . $prefix; - } - } - } - if (empty($prefixes)) { - $prefixes = array($host . '/'); - } - - $ret = true; - foreach ($prefixes as $prefix) { - if (!empty(self::$challenges[$prefix]) && - (empty($challenge['stale']) || strcasecmp('true', $challenge['stale'])) - ) { - // probably credentials are invalid - $ret = false; - } - self::$challenges[$prefix] =& $challenge; - } - return $ret; - } - - /** - * Checks whether another request should be performed with proxy digest auth - * - * Several conditions should be satisfied for it to return true: - * - response status should be 407 - * - proxy auth credentials should be set in the request object - * - response should contain Proxy-Authenticate header with digest challenge - * - there is either no challenge stored for this proxy or new challenge - * contains stale=true parameter (in other case we probably just failed - * due to invalid username / password) - * - * The method stores challenge values in $challenges static property - * - * @param HTTP_Request2_Response response to check - * @return boolean whether another request should be performed - * @throws HTTP_Request2_Exception in case of unsupported challenge parameters - */ - protected function shouldUseProxyDigestAuth(HTTP_Request2_Response $response) - { - if (407 != $response->getStatus() || !$this->request->getConfig('proxy_user')) { - return false; - } - if (!($challenge = $this->parseDigestChallenge($response->getHeader('proxy-authenticate')))) { - return false; - } - - $key = 'proxy://' . $this->request->getConfig('proxy_host') . - ':' . $this->request->getConfig('proxy_port'); - - if (!empty(self::$challenges[$key]) && - (empty($challenge['stale']) || strcasecmp('true', $challenge['stale'])) - ) { - $ret = false; - } else { - $ret = true; - } - self::$challenges[$key] = $challenge; - return $ret; - } - - /** - * Extracts digest method challenge from (WWW|Proxy)-Authenticate header value - * - * There is a problem with implementation of RFC 2617: several of the parameters - * here are defined as quoted-string and thus may contain backslash escaped - * double quotes (RFC 2616, section 2.2). However, RFC 2617 defines unq(X) as - * just value of quoted-string X without surrounding quotes, it doesn't speak - * about removing backslash escaping. - * - * Now realm parameter is user-defined and human-readable, strange things - * happen when it contains quotes: - * - Apache allows quotes in realm, but apparently uses realm value without - * backslashes for digest computation - * - Squid allows (manually escaped) quotes there, but it is impossible to - * authorize with either escaped or unescaped quotes used in digest, - * probably it cannot parse the response (?) - * - Both IE and Firefox display realm value with backslashes in - * the password popup and apparently use the same value for digest - * - * HTTP_Request2 follows IE and Firefox (and hopefully RFC 2617) in - * quoted-string handling, unfortunately that means failure to authorize - * sometimes - * - * @param string value of WWW-Authenticate or Proxy-Authenticate header - * @return mixed associative array with challenge parameters, false if - * no challenge is present in header value - * @throws HTTP_Request2_Exception in case of unsupported challenge parameters - */ - protected function parseDigestChallenge($headerValue) - { - $authParam = '(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' . - self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')'; - $challenge = "!(?<=^|\\s|,)Digest ({$authParam}\\s*(,\\s*|$))+!"; - if (!preg_match($challenge, $headerValue, $matches)) { - return false; - } - - preg_match_all('!' . $authParam . '!', $matches[0], $params); - $paramsAry = array(); - $knownParams = array('realm', 'domain', 'nonce', 'opaque', 'stale', - 'algorithm', 'qop'); - for ($i = 0; $i < count($params[0]); $i++) { - // section 3.2.1: Any unrecognized directive MUST be ignored. - if (in_array($params[1][$i], $knownParams)) { - if ('"' == substr($params[2][$i], 0, 1)) { - $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1); - } else { - $paramsAry[$params[1][$i]] = $params[2][$i]; - } - } - } - // we only support qop=auth - if (!empty($paramsAry['qop']) && - !in_array('auth', array_map('trim', explode(',', $paramsAry['qop']))) - ) { - throw new HTTP_Request2_Exception( - "Only 'auth' qop is currently supported in digest authentication, " . - "server requested '{$paramsAry['qop']}'" - ); - } - // we only support algorithm=MD5 - if (!empty($paramsAry['algorithm']) && 'MD5' != $paramsAry['algorithm']) { - throw new HTTP_Request2_Exception( - "Only 'MD5' algorithm is currently supported in digest authentication, " . - "server requested '{$paramsAry['algorithm']}'" - ); - } - - return $paramsAry; - } - - /** - * Parses [Proxy-]Authentication-Info header value and updates challenge - * - * @param array challenge to update - * @param string value of [Proxy-]Authentication-Info header - * @todo validate server rspauth response - */ - protected function updateChallenge(&$challenge, $headerValue) - { - $authParam = '!(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' . - self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')!'; - $paramsAry = array(); - - preg_match_all($authParam, $headerValue, $params); - for ($i = 0; $i < count($params[0]); $i++) { - if ('"' == substr($params[2][$i], 0, 1)) { - $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1); - } else { - $paramsAry[$params[1][$i]] = $params[2][$i]; - } - } - // for now, just update the nonce value - if (!empty($paramsAry['nextnonce'])) { - $challenge['nonce'] = $paramsAry['nextnonce']; - $challenge['nc'] = 1; - } - } - - /** - * Creates a value for [Proxy-]Authorization header when using digest authentication - * - * @param string user name - * @param string password - * @param string request URL - * @param array digest challenge parameters - * @return string value of [Proxy-]Authorization request header - * @link http://tools.ietf.org/html/rfc2617#section-3.2.2 - */ - protected function createDigestResponse($user, $password, $url, &$challenge) - { - if (false !== ($q = strpos($url, '?')) && - $this->request->getConfig('digest_compat_ie') - ) { - $url = substr($url, 0, $q); - } - - $a1 = md5($user . ':' . $challenge['realm'] . ':' . $password); - $a2 = md5($this->request->getMethod() . ':' . $url); - - if (empty($challenge['qop'])) { - $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $a2); - } else { - $challenge['cnonce'] = 'Req2.' . rand(); - if (empty($challenge['nc'])) { - $challenge['nc'] = 1; - } - $nc = sprintf('%08x', $challenge['nc']++); - $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $nc . ':' . - $challenge['cnonce'] . ':auth:' . $a2); - } - return 'Digest username="' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $user) . '", ' . - 'realm="' . $challenge['realm'] . '", ' . - 'nonce="' . $challenge['nonce'] . '", ' . - 'uri="' . $url . '", ' . - 'response="' . $digest . '"' . - (!empty($challenge['opaque'])? - ', opaque="' . $challenge['opaque'] . '"': - '') . - (!empty($challenge['qop'])? - ', qop="auth", nc=' . $nc . ', cnonce="' . $challenge['cnonce'] . '"': - ''); - } - - /** - * Adds 'Authorization' header (if needed) to request headers array - * - * @param array request headers - * @param string request host (needed for digest authentication) - * @param string request URL (needed for digest authentication) - * @throws HTTP_Request2_Exception - */ - protected function addAuthorizationHeader(&$headers, $requestHost, $requestUrl) - { - if (!($auth = $this->request->getAuth())) { - return; - } - switch ($auth['scheme']) { - case HTTP_Request2::AUTH_BASIC: - $headers['authorization'] = - 'Basic ' . base64_encode($auth['user'] . ':' . $auth['password']); - break; - - case HTTP_Request2::AUTH_DIGEST: - unset($this->serverChallenge); - $fullUrl = ('/' == $requestUrl[0])? - $this->request->getUrl()->getScheme() . '://' . - $requestHost . $requestUrl: - $requestUrl; - foreach (array_keys(self::$challenges) as $key) { - if ($key == substr($fullUrl, 0, strlen($key))) { - $headers['authorization'] = $this->createDigestResponse( - $auth['user'], $auth['password'], - $requestUrl, self::$challenges[$key] - ); - $this->serverChallenge =& self::$challenges[$key]; - break; - } - } - break; - - default: - throw new HTTP_Request2_Exception( - "Unknown HTTP authentication scheme '{$auth['scheme']}'" - ); - } - } - - /** - * Adds 'Proxy-Authorization' header (if needed) to request headers array - * - * @param array request headers - * @param string request URL (needed for digest authentication) - * @throws HTTP_Request2_Exception - */ - protected function addProxyAuthorizationHeader(&$headers, $requestUrl) - { - if (!$this->request->getConfig('proxy_host') || - !($user = $this->request->getConfig('proxy_user')) || - (0 == strcasecmp('https', $this->request->getUrl()->getScheme()) && - HTTP_Request2::METHOD_CONNECT != $this->request->getMethod()) - ) { - return; - } - - $password = $this->request->getConfig('proxy_password'); - switch ($this->request->getConfig('proxy_auth_scheme')) { - case HTTP_Request2::AUTH_BASIC: - $headers['proxy-authorization'] = - 'Basic ' . base64_encode($user . ':' . $password); - break; - - case HTTP_Request2::AUTH_DIGEST: - unset($this->proxyChallenge); - $proxyUrl = 'proxy://' . $this->request->getConfig('proxy_host') . - ':' . $this->request->getConfig('proxy_port'); - if (!empty(self::$challenges[$proxyUrl])) { - $headers['proxy-authorization'] = $this->createDigestResponse( - $user, $password, - $requestUrl, self::$challenges[$proxyUrl] - ); - $this->proxyChallenge =& self::$challenges[$proxyUrl]; - } - break; - - default: - throw new HTTP_Request2_Exception( - "Unknown HTTP authentication scheme '" . - $this->request->getConfig('proxy_auth_scheme') . "'" - ); - } - } - - - /** - * Creates the string with the Request-Line and request headers - * - * @return string - * @throws HTTP_Request2_Exception - */ - protected function prepareHeaders() - { - $headers = $this->request->getHeaders(); - $url = $this->request->getUrl(); - $connect = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod(); - $host = $url->getHost(); - - $defaultPort = 0 == strcasecmp($url->getScheme(), 'https')? 443: 80; - if (($port = $url->getPort()) && $port != $defaultPort || $connect) { - $host .= ':' . (empty($port)? $defaultPort: $port); - } - // Do not overwrite explicitly set 'Host' header, see bug #16146 - if (!isset($headers['host'])) { - $headers['host'] = $host; - } - - if ($connect) { - $requestUrl = $host; - - } else { - if (!$this->request->getConfig('proxy_host') || - 0 == strcasecmp($url->getScheme(), 'https') - ) { - $requestUrl = ''; - } else { - $requestUrl = $url->getScheme() . '://' . $host; - } - $path = $url->getPath(); - $query = $url->getQuery(); - $requestUrl .= (empty($path)? '/': $path) . (empty($query)? '': '?' . $query); - } - - if ('1.1' == $this->request->getConfig('protocol_version') && - extension_loaded('zlib') && !isset($headers['accept-encoding']) - ) { - $headers['accept-encoding'] = 'gzip, deflate'; - } - - $this->addAuthorizationHeader($headers, $host, $requestUrl); - $this->addProxyAuthorizationHeader($headers, $requestUrl); - $this->calculateRequestLength($headers); - - $headersStr = $this->request->getMethod() . ' ' . $requestUrl . ' HTTP/' . - $this->request->getConfig('protocol_version') . "\r\n"; - foreach ($headers as $name => $value) { - $canonicalName = implode('-', array_map('ucfirst', explode('-', $name))); - $headersStr .= $canonicalName . ': ' . $value . "\r\n"; - } - return $headersStr . "\r\n"; - } - - /** - * Sends the request body - * - * @throws HTTP_Request2_Exception - */ - protected function writeBody() - { - if (in_array($this->request->getMethod(), self::$bodyDisallowed) || - 0 == $this->contentLength - ) { - return; - } - - $position = 0; - $bufferSize = $this->request->getConfig('buffer_size'); - while ($position < $this->contentLength) { - if (is_string($this->requestBody)) { - $str = substr($this->requestBody, $position, $bufferSize); - } elseif (is_resource($this->requestBody)) { - $str = fread($this->requestBody, $bufferSize); - } else { - $str = $this->requestBody->read($bufferSize); - } - if (false === @fwrite($this->socket, $str, strlen($str))) { - throw new HTTP_Request2_Exception('Error writing request'); - } - // Provide the length of written string to the observer, request #7630 - $this->request->setLastEvent('sentBodyPart', strlen($str)); - $position += strlen($str); - } - } - - /** - * Reads the remote server's response - * - * @return HTTP_Request2_Response - * @throws HTTP_Request2_Exception - */ - protected function readResponse() - { - $bufferSize = $this->request->getConfig('buffer_size'); - - do { - $response = new HTTP_Request2_Response($this->readLine($bufferSize), true); - do { - $headerLine = $this->readLine($bufferSize); - $response->parseHeaderLine($headerLine); - } while ('' != $headerLine); - } while (in_array($response->getStatus(), array(100, 101))); - - $this->request->setLastEvent('receivedHeaders', $response); - - // No body possible in such responses - if (HTTP_Request2::METHOD_HEAD == $this->request->getMethod() || - (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() && - 200 <= $response->getStatus() && 300 > $response->getStatus()) || - in_array($response->getStatus(), array(204, 304)) - ) { - return $response; - } - - $chunked = 'chunked' == $response->getHeader('transfer-encoding'); - $length = $response->getHeader('content-length'); - $hasBody = false; - if ($chunked || null === $length || 0 < intval($length)) { - // RFC 2616, section 4.4: - // 3. ... If a message is received with both a - // Transfer-Encoding header field and a Content-Length header field, - // the latter MUST be ignored. - $toRead = ($chunked || null === $length)? null: $length; - $this->chunkLength = 0; - - while (!feof($this->socket) && (is_null($toRead) || 0 < $toRead)) { - if ($chunked) { - $data = $this->readChunked($bufferSize); - } elseif (is_null($toRead)) { - $data = $this->fread($bufferSize); - } else { - $data = $this->fread(min($toRead, $bufferSize)); - $toRead -= strlen($data); - } - if ('' == $data && (!$this->chunkLength || feof($this->socket))) { - break; - } - - $hasBody = true; - if ($this->request->getConfig('store_body')) { - $response->appendBody($data); - } - if (!in_array($response->getHeader('content-encoding'), array('identity', null))) { - $this->request->setLastEvent('receivedEncodedBodyPart', $data); - } else { - $this->request->setLastEvent('receivedBodyPart', $data); - } - } - } - - if ($hasBody) { - $this->request->setLastEvent('receivedBody', $response); - } - return $response; - } - - /** - * Reads until either the end of the socket or a newline, whichever comes first - * - * Strips the trailing newline from the returned data, handles global - * request timeout. Method idea borrowed from Net_Socket PEAR package. - * - * @param int buffer size to use for reading - * @return Available data up to the newline (not including newline) - * @throws HTTP_Request2_Exception In case of timeout - */ - protected function readLine($bufferSize) - { - $line = ''; - while (!feof($this->socket)) { - if ($this->timeout) { - stream_set_timeout($this->socket, max($this->timeout - time(), 1)); - } - $line .= @fgets($this->socket, $bufferSize); - $info = stream_get_meta_data($this->socket); - if ($info['timed_out'] || $this->timeout && time() > $this->timeout) { - throw new HTTP_Request2_Exception( - 'Request timed out after ' . - $this->request->getConfig('timeout') . ' second(s)' - ); - } - if (substr($line, -1) == "\n") { - return rtrim($line, "\r\n"); - } - } - return $line; - } - - /** - * Wrapper around fread(), handles global request timeout - * - * @param int Reads up to this number of bytes - * @return Data read from socket - * @throws HTTP_Request2_Exception In case of timeout - */ - protected function fread($length) - { - if ($this->timeout) { - stream_set_timeout($this->socket, max($this->timeout - time(), 1)); - } - $data = fread($this->socket, $length); - $info = stream_get_meta_data($this->socket); - if ($info['timed_out'] || $this->timeout && time() > $this->timeout) { - throw new HTTP_Request2_Exception( - 'Request timed out after ' . - $this->request->getConfig('timeout') . ' second(s)' - ); - } - return $data; - } - - /** - * Reads a part of response body encoded with chunked Transfer-Encoding - * - * @param int buffer size to use for reading - * @return string - * @throws HTTP_Request2_Exception - */ - protected function readChunked($bufferSize) - { - // at start of the next chunk? - if (0 == $this->chunkLength) { - $line = $this->readLine($bufferSize); - if (!preg_match('/^([0-9a-f]+)/i', $line, $matches)) { - throw new HTTP_Request2_Exception( - "Cannot decode chunked response, invalid chunk length '{$line}'" - ); - } else { - $this->chunkLength = hexdec($matches[1]); - // Chunk with zero length indicates the end - if (0 == $this->chunkLength) { - $this->readLine($bufferSize); - return ''; - } - } - } - $data = $this->fread(min($this->chunkLength, $bufferSize)); - $this->chunkLength -= strlen($data); - if (0 == $this->chunkLength) { - $this->readLine($bufferSize); // Trailing CRLF - } - return $data; - } -} - + + * 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. + * * The names of the authors may not be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * 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. + * + * @category HTTP + * @package HTTP_Request2 + * @author Alexey Borzov + * @license http://opensource.org/licenses/bsd-license.php New BSD License + * @version CVS: $Id: Socket.php 279760 2009-05-03 10:46:42Z avb $ + * @link http://pear.php.net/package/HTTP_Request2 + */ + +/** + * Base class for HTTP_Request2 adapters + */ +require_once 'HTTP/Request2/Adapter.php'; + +/** + * Socket-based adapter for HTTP_Request2 + * + * This adapter uses only PHP sockets and will work on almost any PHP + * environment. Code is based on original HTTP_Request PEAR package. + * + * @category HTTP + * @package HTTP_Request2 + * @author Alexey Borzov + * @version Release: 0.4.1 + */ +class HTTP_Request2_Adapter_Socket extends HTTP_Request2_Adapter +{ + /** + * Regular expression for 'token' rule from RFC 2616 + */ + const REGEXP_TOKEN = '[^\x00-\x1f\x7f-\xff()<>@,;:\\\\"/\[\]?={}\s]+'; + + /** + * Regular expression for 'quoted-string' rule from RFC 2616 + */ + const REGEXP_QUOTED_STRING = '"(?:\\\\.|[^\\\\"])*"'; + + /** + * Connected sockets, needed for Keep-Alive support + * @var array + * @see connect() + */ + protected static $sockets = array(); + + /** + * Data for digest authentication scheme + * + * The keys for the array are URL prefixes. + * + * The values are associative arrays with data (realm, nonce, nonce-count, + * opaque...) needed for digest authentication. Stored here to prevent making + * duplicate requests to digest-protected resources after we have already + * received the challenge. + * + * @var array + */ + protected static $challenges = array(); + + /** + * Connected socket + * @var resource + * @see connect() + */ + protected $socket; + + /** + * Challenge used for server digest authentication + * @var array + */ + protected $serverChallenge; + + /** + * Challenge used for proxy digest authentication + * @var array + */ + protected $proxyChallenge; + + /** + * Global timeout, exception will be raised if request continues past this time + * @var integer + */ + protected $timeout = null; + + /** + * Remaining length of the current chunk, when reading chunked response + * @var integer + * @see readChunked() + */ + protected $chunkLength = 0; + + /** + * Sends request to the remote server and returns its response + * + * @param HTTP_Request2 + * @return HTTP_Request2_Response + * @throws HTTP_Request2_Exception + */ + public function sendRequest(HTTP_Request2 $request) + { + $this->request = $request; + $keepAlive = $this->connect(); + $headers = $this->prepareHeaders(); + + // Use global request timeout if given, see feature requests #5735, #8964 + if ($timeout = $request->getConfig('timeout')) { + $this->timeout = time() + $timeout; + } else { + $this->timeout = null; + } + + try { + if (false === @fwrite($this->socket, $headers, strlen($headers))) { + throw new HTTP_Request2_Exception('Error writing request'); + } + // provide request headers to the observer, see request #7633 + $this->request->setLastEvent('sentHeaders', $headers); + $this->writeBody(); + + if ($this->timeout && time() > $this->timeout) { + throw new HTTP_Request2_Exception( + 'Request timed out after ' . + $request->getConfig('timeout') . ' second(s)' + ); + } + + $response = $this->readResponse(); + + if (!$this->canKeepAlive($keepAlive, $response)) { + $this->disconnect(); + } + + if ($this->shouldUseProxyDigestAuth($response)) { + return $this->sendRequest($request); + } + if ($this->shouldUseServerDigestAuth($response)) { + return $this->sendRequest($request); + } + if ($authInfo = $response->getHeader('authentication-info')) { + $this->updateChallenge($this->serverChallenge, $authInfo); + } + if ($proxyInfo = $response->getHeader('proxy-authentication-info')) { + $this->updateChallenge($this->proxyChallenge, $proxyInfo); + } + + } catch (Exception $e) { + $this->disconnect(); + throw $e; + } + + return $response; + } + + /** + * Connects to the remote server + * + * @return bool whether the connection can be persistent + * @throws HTTP_Request2_Exception + */ + protected function connect() + { + $secure = 0 == strcasecmp($this->request->getUrl()->getScheme(), 'https'); + $tunnel = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod(); + $headers = $this->request->getHeaders(); + $reqHost = $this->request->getUrl()->getHost(); + if (!($reqPort = $this->request->getUrl()->getPort())) { + $reqPort = $secure? 443: 80; + } + + if ($host = $this->request->getConfig('proxy_host')) { + if (!($port = $this->request->getConfig('proxy_port'))) { + throw new HTTP_Request2_Exception('Proxy port not provided'); + } + $proxy = true; + } else { + $host = $reqHost; + $port = $reqPort; + $proxy = false; + } + + if ($tunnel && !$proxy) { + throw new HTTP_Request2_Exception( + "Trying to perform CONNECT request without proxy" + ); + } + if ($secure && !in_array('ssl', stream_get_transports())) { + throw new HTTP_Request2_Exception( + 'Need OpenSSL support for https:// requests' + ); + } + + // RFC 2068, section 19.7.1: A client MUST NOT send the Keep-Alive + // connection token to a proxy server... + if ($proxy && !$secure && + !empty($headers['connection']) && 'Keep-Alive' == $headers['connection'] + ) { + $this->request->setHeader('connection'); + } + + $keepAlive = ('1.1' == $this->request->getConfig('protocol_version') && + empty($headers['connection'])) || + (!empty($headers['connection']) && + 'Keep-Alive' == $headers['connection']); + $host = ((!$secure || $proxy)? 'tcp://': 'ssl://') . $host; + + $options = array(); + if ($secure || $tunnel) { + foreach ($this->request->getConfig() as $name => $value) { + if ('ssl_' == substr($name, 0, 4) && null !== $value) { + if ('ssl_verify_host' == $name) { + if ($value) { + $options['CN_match'] = $reqHost; + } + } else { + $options[substr($name, 4)] = $value; + } + } + } + ksort($options); + } + + // Changing SSL context options after connection is established does *not* + // work, we need a new connection if options change + $remote = $host . ':' . $port; + $socketKey = $remote . (($secure && $proxy)? "->{$reqHost}:{$reqPort}": '') . + (empty($options)? '': ':' . serialize($options)); + unset($this->socket); + + // We use persistent connections and have a connected socket? + // Ensure that the socket is still connected, see bug #16149 + if ($keepAlive && !empty(self::$sockets[$socketKey]) && + !feof(self::$sockets[$socketKey]) + ) { + $this->socket =& self::$sockets[$socketKey]; + + } elseif ($secure && $proxy && !$tunnel) { + $this->establishTunnel(); + $this->request->setLastEvent( + 'connect', "ssl://{$reqHost}:{$reqPort} via {$host}:{$port}" + ); + self::$sockets[$socketKey] =& $this->socket; + + } else { + // Set SSL context options if doing HTTPS request or creating a tunnel + $context = stream_context_create(); + foreach ($options as $name => $value) { + if (!stream_context_set_option($context, 'ssl', $name, $value)) { + throw new HTTP_Request2_Exception( + "Error setting SSL context option '{$name}'" + ); + } + } + $this->socket = @stream_socket_client( + $remote, $errno, $errstr, + $this->request->getConfig('connect_timeout'), + STREAM_CLIENT_CONNECT, $context + ); + if (!$this->socket) { + throw new HTTP_Request2_Exception( + "Unable to connect to {$remote}. Error #{$errno}: {$errstr}" + ); + } + $this->request->setLastEvent('connect', $remote); + self::$sockets[$socketKey] =& $this->socket; + } + return $keepAlive; + } + + /** + * Establishes a tunnel to a secure remote server via HTTP CONNECT request + * + * This method will fail if 'ssl_verify_peer' is enabled. Probably because PHP + * sees that we are connected to a proxy server (duh!) rather than the server + * that presents its certificate. + * + * @link http://tools.ietf.org/html/rfc2817#section-5.2 + * @throws HTTP_Request2_Exception + */ + protected function establishTunnel() + { + $donor = new self; + $connect = new HTTP_Request2( + $this->request->getUrl(), HTTP_Request2::METHOD_CONNECT, + array_merge($this->request->getConfig(), + array('adapter' => $donor)) + ); + $response = $connect->send(); + // Need any successful (2XX) response + if (200 > $response->getStatus() || 300 <= $response->getStatus()) { + throw new HTTP_Request2_Exception( + 'Failed to connect via HTTPS proxy. Proxy response: ' . + $response->getStatus() . ' ' . $response->getReasonPhrase() + ); + } + $this->socket = $donor->socket; + + $modes = array( + STREAM_CRYPTO_METHOD_TLS_CLIENT, + STREAM_CRYPTO_METHOD_SSLv3_CLIENT, + STREAM_CRYPTO_METHOD_SSLv23_CLIENT, + STREAM_CRYPTO_METHOD_SSLv2_CLIENT + ); + + foreach ($modes as $mode) { + if (stream_socket_enable_crypto($this->socket, true, $mode)) { + return; + } + } + throw new HTTP_Request2_Exception( + 'Failed to enable secure connection when connecting through proxy' + ); + } + + /** + * Checks whether current connection may be reused or should be closed + * + * @param boolean whether connection could be persistent + * in the first place + * @param HTTP_Request2_Response response object to check + * @return boolean + */ + protected function canKeepAlive($requestKeepAlive, HTTP_Request2_Response $response) + { + // Do not close socket on successful CONNECT request + if (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() && + 200 <= $response->getStatus() && 300 > $response->getStatus() + ) { + return true; + } + + $lengthKnown = 'chunked' == strtolower($response->getHeader('transfer-encoding')) || + null !== $response->getHeader('content-length'); + $persistent = 'keep-alive' == strtolower($response->getHeader('connection')) || + (null === $response->getHeader('connection') && + '1.1' == $response->getVersion()); + return $requestKeepAlive && $lengthKnown && $persistent; + } + + /** + * Disconnects from the remote server + */ + protected function disconnect() + { + if (is_resource($this->socket)) { + fclose($this->socket); + $this->socket = null; + $this->request->setLastEvent('disconnect'); + } + } + + /** + * Checks whether another request should be performed with server digest auth + * + * Several conditions should be satisfied for it to return true: + * - response status should be 401 + * - auth credentials should be set in the request object + * - response should contain WWW-Authenticate header with digest challenge + * - there is either no challenge stored for this URL or new challenge + * contains stale=true parameter (in other case we probably just failed + * due to invalid username / password) + * + * The method stores challenge values in $challenges static property + * + * @param HTTP_Request2_Response response to check + * @return boolean whether another request should be performed + * @throws HTTP_Request2_Exception in case of unsupported challenge parameters + */ + protected function shouldUseServerDigestAuth(HTTP_Request2_Response $response) + { + // no sense repeating a request if we don't have credentials + if (401 != $response->getStatus() || !$this->request->getAuth()) { + return false; + } + if (!$challenge = $this->parseDigestChallenge($response->getHeader('www-authenticate'))) { + return false; + } + + $url = $this->request->getUrl(); + $scheme = $url->getScheme(); + $host = $scheme . '://' . $url->getHost(); + if ($port = $url->getPort()) { + if ((0 == strcasecmp($scheme, 'http') && 80 != $port) || + (0 == strcasecmp($scheme, 'https') && 443 != $port) + ) { + $host .= ':' . $port; + } + } + + if (!empty($challenge['domain'])) { + $prefixes = array(); + foreach (preg_split('/\\s+/', $challenge['domain']) as $prefix) { + // don't bother with different servers + if ('/' == substr($prefix, 0, 1)) { + $prefixes[] = $host . $prefix; + } + } + } + if (empty($prefixes)) { + $prefixes = array($host . '/'); + } + + $ret = true; + foreach ($prefixes as $prefix) { + if (!empty(self::$challenges[$prefix]) && + (empty($challenge['stale']) || strcasecmp('true', $challenge['stale'])) + ) { + // probably credentials are invalid + $ret = false; + } + self::$challenges[$prefix] =& $challenge; + } + return $ret; + } + + /** + * Checks whether another request should be performed with proxy digest auth + * + * Several conditions should be satisfied for it to return true: + * - response status should be 407 + * - proxy auth credentials should be set in the request object + * - response should contain Proxy-Authenticate header with digest challenge + * - there is either no challenge stored for this proxy or new challenge + * contains stale=true parameter (in other case we probably just failed + * due to invalid username / password) + * + * The method stores challenge values in $challenges static property + * + * @param HTTP_Request2_Response response to check + * @return boolean whether another request should be performed + * @throws HTTP_Request2_Exception in case of unsupported challenge parameters + */ + protected function shouldUseProxyDigestAuth(HTTP_Request2_Response $response) + { + if (407 != $response->getStatus() || !$this->request->getConfig('proxy_user')) { + return false; + } + if (!($challenge = $this->parseDigestChallenge($response->getHeader('proxy-authenticate')))) { + return false; + } + + $key = 'proxy://' . $this->request->getConfig('proxy_host') . + ':' . $this->request->getConfig('proxy_port'); + + if (!empty(self::$challenges[$key]) && + (empty($challenge['stale']) || strcasecmp('true', $challenge['stale'])) + ) { + $ret = false; + } else { + $ret = true; + } + self::$challenges[$key] = $challenge; + return $ret; + } + + /** + * Extracts digest method challenge from (WWW|Proxy)-Authenticate header value + * + * There is a problem with implementation of RFC 2617: several of the parameters + * here are defined as quoted-string and thus may contain backslash escaped + * double quotes (RFC 2616, section 2.2). However, RFC 2617 defines unq(X) as + * just value of quoted-string X without surrounding quotes, it doesn't speak + * about removing backslash escaping. + * + * Now realm parameter is user-defined and human-readable, strange things + * happen when it contains quotes: + * - Apache allows quotes in realm, but apparently uses realm value without + * backslashes for digest computation + * - Squid allows (manually escaped) quotes there, but it is impossible to + * authorize with either escaped or unescaped quotes used in digest, + * probably it can't parse the response (?) + * - Both IE and Firefox display realm value with backslashes in + * the password popup and apparently use the same value for digest + * + * HTTP_Request2 follows IE and Firefox (and hopefully RFC 2617) in + * quoted-string handling, unfortunately that means failure to authorize + * sometimes + * + * @param string value of WWW-Authenticate or Proxy-Authenticate header + * @return mixed associative array with challenge parameters, false if + * no challenge is present in header value + * @throws HTTP_Request2_Exception in case of unsupported challenge parameters + */ + protected function parseDigestChallenge($headerValue) + { + $authParam = '(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' . + self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')'; + $challenge = "!(?<=^|\\s|,)Digest ({$authParam}\\s*(,\\s*|$))+!"; + if (!preg_match($challenge, $headerValue, $matches)) { + return false; + } + + preg_match_all('!' . $authParam . '!', $matches[0], $params); + $paramsAry = array(); + $knownParams = array('realm', 'domain', 'nonce', 'opaque', 'stale', + 'algorithm', 'qop'); + for ($i = 0; $i < count($params[0]); $i++) { + // section 3.2.1: Any unrecognized directive MUST be ignored. + if (in_array($params[1][$i], $knownParams)) { + if ('"' == substr($params[2][$i], 0, 1)) { + $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1); + } else { + $paramsAry[$params[1][$i]] = $params[2][$i]; + } + } + } + // we only support qop=auth + if (!empty($paramsAry['qop']) && + !in_array('auth', array_map('trim', explode(',', $paramsAry['qop']))) + ) { + throw new HTTP_Request2_Exception( + "Only 'auth' qop is currently supported in digest authentication, " . + "server requested '{$paramsAry['qop']}'" + ); + } + // we only support algorithm=MD5 + if (!empty($paramsAry['algorithm']) && 'MD5' != $paramsAry['algorithm']) { + throw new HTTP_Request2_Exception( + "Only 'MD5' algorithm is currently supported in digest authentication, " . + "server requested '{$paramsAry['algorithm']}'" + ); + } + + return $paramsAry; + } + + /** + * Parses [Proxy-]Authentication-Info header value and updates challenge + * + * @param array challenge to update + * @param string value of [Proxy-]Authentication-Info header + * @todo validate server rspauth response + */ + protected function updateChallenge(&$challenge, $headerValue) + { + $authParam = '!(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' . + self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')!'; + $paramsAry = array(); + + preg_match_all($authParam, $headerValue, $params); + for ($i = 0; $i < count($params[0]); $i++) { + if ('"' == substr($params[2][$i], 0, 1)) { + $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1); + } else { + $paramsAry[$params[1][$i]] = $params[2][$i]; + } + } + // for now, just update the nonce value + if (!empty($paramsAry['nextnonce'])) { + $challenge['nonce'] = $paramsAry['nextnonce']; + $challenge['nc'] = 1; + } + } + + /** + * Creates a value for [Proxy-]Authorization header when using digest authentication + * + * @param string user name + * @param string password + * @param string request URL + * @param array digest challenge parameters + * @return string value of [Proxy-]Authorization request header + * @link http://tools.ietf.org/html/rfc2617#section-3.2.2 + */ + protected function createDigestResponse($user, $password, $url, &$challenge) + { + if (false !== ($q = strpos($url, '?')) && + $this->request->getConfig('digest_compat_ie') + ) { + $url = substr($url, 0, $q); + } + + $a1 = md5($user . ':' . $challenge['realm'] . ':' . $password); + $a2 = md5($this->request->getMethod() . ':' . $url); + + if (empty($challenge['qop'])) { + $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $a2); + } else { + $challenge['cnonce'] = 'Req2.' . rand(); + if (empty($challenge['nc'])) { + $challenge['nc'] = 1; + } + $nc = sprintf('%08x', $challenge['nc']++); + $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $nc . ':' . + $challenge['cnonce'] . ':auth:' . $a2); + } + return 'Digest username="' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $user) . '", ' . + 'realm="' . $challenge['realm'] . '", ' . + 'nonce="' . $challenge['nonce'] . '", ' . + 'uri="' . $url . '", ' . + 'response="' . $digest . '"' . + (!empty($challenge['opaque'])? + ', opaque="' . $challenge['opaque'] . '"': + '') . + (!empty($challenge['qop'])? + ', qop="auth", nc=' . $nc . ', cnonce="' . $challenge['cnonce'] . '"': + ''); + } + + /** + * Adds 'Authorization' header (if needed) to request headers array + * + * @param array request headers + * @param string request host (needed for digest authentication) + * @param string request URL (needed for digest authentication) + * @throws HTTP_Request2_Exception + */ + protected function addAuthorizationHeader(&$headers, $requestHost, $requestUrl) + { + if (!($auth = $this->request->getAuth())) { + return; + } + switch ($auth['scheme']) { + case HTTP_Request2::AUTH_BASIC: + $headers['authorization'] = + 'Basic ' . base64_encode($auth['user'] . ':' . $auth['password']); + break; + + case HTTP_Request2::AUTH_DIGEST: + unset($this->serverChallenge); + $fullUrl = ('/' == $requestUrl[0])? + $this->request->getUrl()->getScheme() . '://' . + $requestHost . $requestUrl: + $requestUrl; + foreach (array_keys(self::$challenges) as $key) { + if ($key == substr($fullUrl, 0, strlen($key))) { + $headers['authorization'] = $this->createDigestResponse( + $auth['user'], $auth['password'], + $requestUrl, self::$challenges[$key] + ); + $this->serverChallenge =& self::$challenges[$key]; + break; + } + } + break; + + default: + throw new HTTP_Request2_Exception( + "Unknown HTTP authentication scheme '{$auth['scheme']}'" + ); + } + } + + /** + * Adds 'Proxy-Authorization' header (if needed) to request headers array + * + * @param array request headers + * @param string request URL (needed for digest authentication) + * @throws HTTP_Request2_Exception + */ + protected function addProxyAuthorizationHeader(&$headers, $requestUrl) + { + if (!$this->request->getConfig('proxy_host') || + !($user = $this->request->getConfig('proxy_user')) || + (0 == strcasecmp('https', $this->request->getUrl()->getScheme()) && + HTTP_Request2::METHOD_CONNECT != $this->request->getMethod()) + ) { + return; + } + + $password = $this->request->getConfig('proxy_password'); + switch ($this->request->getConfig('proxy_auth_scheme')) { + case HTTP_Request2::AUTH_BASIC: + $headers['proxy-authorization'] = + 'Basic ' . base64_encode($user . ':' . $password); + break; + + case HTTP_Request2::AUTH_DIGEST: + unset($this->proxyChallenge); + $proxyUrl = 'proxy://' . $this->request->getConfig('proxy_host') . + ':' . $this->request->getConfig('proxy_port'); + if (!empty(self::$challenges[$proxyUrl])) { + $headers['proxy-authorization'] = $this->createDigestResponse( + $user, $password, + $requestUrl, self::$challenges[$proxyUrl] + ); + $this->proxyChallenge =& self::$challenges[$proxyUrl]; + } + break; + + default: + throw new HTTP_Request2_Exception( + "Unknown HTTP authentication scheme '" . + $this->request->getConfig('proxy_auth_scheme') . "'" + ); + } + } + + + /** + * Creates the string with the Request-Line and request headers + * + * @return string + * @throws HTTP_Request2_Exception + */ + protected function prepareHeaders() + { + $headers = $this->request->getHeaders(); + $url = $this->request->getUrl(); + $connect = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod(); + $host = $url->getHost(); + + $defaultPort = 0 == strcasecmp($url->getScheme(), 'https')? 443: 80; + if (($port = $url->getPort()) && $port != $defaultPort || $connect) { + $host .= ':' . (empty($port)? $defaultPort: $port); + } + // Do not overwrite explicitly set 'Host' header, see bug #16146 + if (!isset($headers['host'])) { + $headers['host'] = $host; + } + + if ($connect) { + $requestUrl = $host; + + } else { + if (!$this->request->getConfig('proxy_host') || + 0 == strcasecmp($url->getScheme(), 'https') + ) { + $requestUrl = ''; + } else { + $requestUrl = $url->getScheme() . '://' . $host; + } + $path = $url->getPath(); + $query = $url->getQuery(); + $requestUrl .= (empty($path)? '/': $path) . (empty($query)? '': '?' . $query); + } + + if ('1.1' == $this->request->getConfig('protocol_version') && + extension_loaded('zlib') && !isset($headers['accept-encoding']) + ) { + $headers['accept-encoding'] = 'gzip, deflate'; + } + + $this->addAuthorizationHeader($headers, $host, $requestUrl); + $this->addProxyAuthorizationHeader($headers, $requestUrl); + $this->calculateRequestLength($headers); + + $headersStr = $this->request->getMethod() . ' ' . $requestUrl . ' HTTP/' . + $this->request->getConfig('protocol_version') . "\r\n"; + foreach ($headers as $name => $value) { + $canonicalName = implode('-', array_map('ucfirst', explode('-', $name))); + $headersStr .= $canonicalName . ': ' . $value . "\r\n"; + } + return $headersStr . "\r\n"; + } + + /** + * Sends the request body + * + * @throws HTTP_Request2_Exception + */ + protected function writeBody() + { + if (in_array($this->request->getMethod(), self::$bodyDisallowed) || + 0 == $this->contentLength + ) { + return; + } + + $position = 0; + $bufferSize = $this->request->getConfig('buffer_size'); + while ($position < $this->contentLength) { + if (is_string($this->requestBody)) { + $str = substr($this->requestBody, $position, $bufferSize); + } elseif (is_resource($this->requestBody)) { + $str = fread($this->requestBody, $bufferSize); + } else { + $str = $this->requestBody->read($bufferSize); + } + if (false === @fwrite($this->socket, $str, strlen($str))) { + throw new HTTP_Request2_Exception('Error writing request'); + } + // Provide the length of written string to the observer, request #7630 + $this->request->setLastEvent('sentBodyPart', strlen($str)); + $position += strlen($str); + } + } + + /** + * Reads the remote server's response + * + * @return HTTP_Request2_Response + * @throws HTTP_Request2_Exception + */ + protected function readResponse() + { + $bufferSize = $this->request->getConfig('buffer_size'); + + do { + $response = new HTTP_Request2_Response($this->readLine($bufferSize), true); + do { + $headerLine = $this->readLine($bufferSize); + $response->parseHeaderLine($headerLine); + } while ('' != $headerLine); + } while (in_array($response->getStatus(), array(100, 101))); + + $this->request->setLastEvent('receivedHeaders', $response); + + // No body possible in such responses + if (HTTP_Request2::METHOD_HEAD == $this->request->getMethod() || + (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() && + 200 <= $response->getStatus() && 300 > $response->getStatus()) || + in_array($response->getStatus(), array(204, 304)) + ) { + return $response; + } + + $chunked = 'chunked' == $response->getHeader('transfer-encoding'); + $length = $response->getHeader('content-length'); + $hasBody = false; + if ($chunked || null === $length || 0 < intval($length)) { + // RFC 2616, section 4.4: + // 3. ... If a message is received with both a + // Transfer-Encoding header field and a Content-Length header field, + // the latter MUST be ignored. + $toRead = ($chunked || null === $length)? null: $length; + $this->chunkLength = 0; + + while (!feof($this->socket) && (is_null($toRead) || 0 < $toRead)) { + if ($chunked) { + $data = $this->readChunked($bufferSize); + } elseif (is_null($toRead)) { + $data = $this->fread($bufferSize); + } else { + $data = $this->fread(min($toRead, $bufferSize)); + $toRead -= strlen($data); + } + if ('' == $data && (!$this->chunkLength || feof($this->socket))) { + break; + } + + $hasBody = true; + if ($this->request->getConfig('store_body')) { + $response->appendBody($data); + } + if (!in_array($response->getHeader('content-encoding'), array('identity', null))) { + $this->request->setLastEvent('receivedEncodedBodyPart', $data); + } else { + $this->request->setLastEvent('receivedBodyPart', $data); + } + } + } + + if ($hasBody) { + $this->request->setLastEvent('receivedBody', $response); + } + return $response; + } + + /** + * Reads until either the end of the socket or a newline, whichever comes first + * + * Strips the trailing newline from the returned data, handles global + * request timeout. Method idea borrowed from Net_Socket PEAR package. + * + * @param int buffer size to use for reading + * @return Available data up to the newline (not including newline) + * @throws HTTP_Request2_Exception In case of timeout + */ + protected function readLine($bufferSize) + { + $line = ''; + while (!feof($this->socket)) { + if ($this->timeout) { + stream_set_timeout($this->socket, max($this->timeout - time(), 1)); + } + $line .= @fgets($this->socket, $bufferSize); + $info = stream_get_meta_data($this->socket); + if ($info['timed_out'] || $this->timeout && time() > $this->timeout) { + throw new HTTP_Request2_Exception( + 'Request timed out after ' . + $this->request->getConfig('timeout') . ' second(s)' + ); + } + if (substr($line, -1) == "\n") { + return rtrim($line, "\r\n"); + } + } + return $line; + } + + /** + * Wrapper around fread(), handles global request timeout + * + * @param int Reads up to this number of bytes + * @return Data read from socket + * @throws HTTP_Request2_Exception In case of timeout + */ + protected function fread($length) + { + if ($this->timeout) { + stream_set_timeout($this->socket, max($this->timeout - time(), 1)); + } + $data = fread($this->socket, $length); + $info = stream_get_meta_data($this->socket); + if ($info['timed_out'] || $this->timeout && time() > $this->timeout) { + throw new HTTP_Request2_Exception( + 'Request timed out after ' . + $this->request->getConfig('timeout') . ' second(s)' + ); + } + return $data; + } + + /** + * Reads a part of response body encoded with chunked Transfer-Encoding + * + * @param int buffer size to use for reading + * @return string + * @throws HTTP_Request2_Exception + */ + protected function readChunked($bufferSize) + { + // at start of the next chunk? + if (0 == $this->chunkLength) { + $line = $this->readLine($bufferSize); + if (!preg_match('/^([0-9a-f]+)/i', $line, $matches)) { + throw new HTTP_Request2_Exception( + "Cannot decode chunked response, invalid chunk length '{$line}'" + ); + } else { + $this->chunkLength = hexdec($matches[1]); + // Chunk with zero length indicates the end + if (0 == $this->chunkLength) { + $this->readLine($bufferSize); + return ''; + } + } + } + $data = $this->fread(min($this->chunkLength, $bufferSize)); + $this->chunkLength -= strlen($data); + if (0 == $this->chunkLength) { + $this->readLine($bufferSize); // Trailing CRLF + } + return $data; + } +} + ?> \ No newline at end of file -- cgit v1.2.3-54-g00ecf