PageRenderTime 55ms CodeModel.GetById 17ms RepoModel.GetById 1ms app.codeStats 0ms

/src/frapi/library/PEAR/HTTP/Request2/Adapter/Socket.php

http://github.com/frapi/frapi
PHP | 1084 lines | 678 code | 85 blank | 321 comment | 198 complexity | bb4bed4b32f0f8195bd5c5c19155c68d MD5 | raw file
Possible License(s): BSD-2-Clause
  1. <?php
  2. /**
  3. * Socket-based adapter for HTTP_Request2
  4. *
  5. * PHP version 5
  6. *
  7. * LICENSE:
  8. *
  9. * Copyright (c) 2008-2011, Alexey Borzov <avb@php.net>
  10. * All rights reserved.
  11. *
  12. * Redistribution and use in source and binary forms, with or without
  13. * modification, are permitted provided that the following conditions
  14. * are met:
  15. *
  16. * * Redistributions of source code must retain the above copyright
  17. * notice, this list of conditions and the following disclaimer.
  18. * * Redistributions in binary form must reproduce the above copyright
  19. * notice, this list of conditions and the following disclaimer in the
  20. * documentation and/or other materials provided with the distribution.
  21. * * The names of the authors may not be used to endorse or promote products
  22. * derived from this software without specific prior written permission.
  23. *
  24. * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
  25. * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
  26. * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
  27. * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
  28. * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
  29. * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
  30. * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
  31. * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
  32. * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
  33. * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  34. * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  35. *
  36. * @category HTTP
  37. * @package HTTP_Request2
  38. * @author Alexey Borzov <avb@php.net>
  39. * @license http://opensource.org/licenses/bsd-license.php New BSD License
  40. * @version SVN: $Id: Socket.php 309921 2011-04-03 16:43:02Z avb $
  41. * @link http://pear.php.net/package/HTTP_Request2
  42. */
  43. /**
  44. * Base class for HTTP_Request2 adapters
  45. */
  46. require_once 'HTTP/Request2/Adapter.php';
  47. /**
  48. * Socket-based adapter for HTTP_Request2
  49. *
  50. * This adapter uses only PHP sockets and will work on almost any PHP
  51. * environment. Code is based on original HTTP_Request PEAR package.
  52. *
  53. * @category HTTP
  54. * @package HTTP_Request2
  55. * @author Alexey Borzov <avb@php.net>
  56. * @version Release: 2.0.0RC1
  57. */
  58. class HTTP_Request2_Adapter_Socket extends HTTP_Request2_Adapter
  59. {
  60. /**
  61. * Regular expression for 'token' rule from RFC 2616
  62. */
  63. const REGEXP_TOKEN = '[^\x00-\x1f\x7f-\xff()<>@,;:\\\\"/\[\]?={}\s]+';
  64. /**
  65. * Regular expression for 'quoted-string' rule from RFC 2616
  66. */
  67. const REGEXP_QUOTED_STRING = '"(?:\\\\.|[^\\\\"])*"';
  68. /**
  69. * Connected sockets, needed for Keep-Alive support
  70. * @var array
  71. * @see connect()
  72. */
  73. protected static $sockets = array();
  74. /**
  75. * Data for digest authentication scheme
  76. *
  77. * The keys for the array are URL prefixes.
  78. *
  79. * The values are associative arrays with data (realm, nonce, nonce-count,
  80. * opaque...) needed for digest authentication. Stored here to prevent making
  81. * duplicate requests to digest-protected resources after we have already
  82. * received the challenge.
  83. *
  84. * @var array
  85. */
  86. protected static $challenges = array();
  87. /**
  88. * Connected socket
  89. * @var resource
  90. * @see connect()
  91. */
  92. protected $socket;
  93. /**
  94. * Challenge used for server digest authentication
  95. * @var array
  96. */
  97. protected $serverChallenge;
  98. /**
  99. * Challenge used for proxy digest authentication
  100. * @var array
  101. */
  102. protected $proxyChallenge;
  103. /**
  104. * Sum of start time and global timeout, exception will be thrown if request continues past this time
  105. * @var integer
  106. */
  107. protected $deadline = null;
  108. /**
  109. * Remaining length of the current chunk, when reading chunked response
  110. * @var integer
  111. * @see readChunked()
  112. */
  113. protected $chunkLength = 0;
  114. /**
  115. * Remaining amount of redirections to follow
  116. *
  117. * Starts at 'max_redirects' configuration parameter and is reduced on each
  118. * subsequent redirect. An Exception will be thrown once it reaches zero.
  119. *
  120. * @var integer
  121. */
  122. protected $redirectCountdown = null;
  123. /**
  124. * Sends request to the remote server and returns its response
  125. *
  126. * @param HTTP_Request2
  127. * @return HTTP_Request2_Response
  128. * @throws HTTP_Request2_Exception
  129. */
  130. public function sendRequest(HTTP_Request2 $request)
  131. {
  132. $this->request = $request;
  133. // Use global request timeout if given, see feature requests #5735, #8964
  134. if ($timeout = $request->getConfig('timeout')) {
  135. $this->deadline = time() + $timeout;
  136. } else {
  137. $this->deadline = null;
  138. }
  139. try {
  140. $keepAlive = $this->connect();
  141. $headers = $this->prepareHeaders();
  142. if (false === @fwrite($this->socket, $headers, strlen($headers))) {
  143. throw new HTTP_Request2_MessageException('Error writing request');
  144. }
  145. // provide request headers to the observer, see request #7633
  146. $this->request->setLastEvent('sentHeaders', $headers);
  147. $this->writeBody();
  148. if ($this->deadline && time() > $this->deadline) {
  149. throw new HTTP_Request2_MessageException(
  150. 'Request timed out after ' .
  151. $request->getConfig('timeout') . ' second(s)',
  152. HTTP_Request2_Exception::TIMEOUT
  153. );
  154. }
  155. $response = $this->readResponse();
  156. if ($jar = $request->getCookieJar()) {
  157. $jar->addCookiesFromResponse($response, $request->getUrl());
  158. }
  159. if (!$this->canKeepAlive($keepAlive, $response)) {
  160. $this->disconnect();
  161. }
  162. if ($this->shouldUseProxyDigestAuth($response)) {
  163. return $this->sendRequest($request);
  164. }
  165. if ($this->shouldUseServerDigestAuth($response)) {
  166. return $this->sendRequest($request);
  167. }
  168. if ($authInfo = $response->getHeader('authentication-info')) {
  169. $this->updateChallenge($this->serverChallenge, $authInfo);
  170. }
  171. if ($proxyInfo = $response->getHeader('proxy-authentication-info')) {
  172. $this->updateChallenge($this->proxyChallenge, $proxyInfo);
  173. }
  174. } catch (Exception $e) {
  175. $this->disconnect();
  176. }
  177. unset($this->request, $this->requestBody);
  178. if (!empty($e)) {
  179. $this->redirectCountdown = null;
  180. throw $e;
  181. }
  182. if (!$request->getConfig('follow_redirects') || !$response->isRedirect()) {
  183. $this->redirectCountdown = null;
  184. return $response;
  185. } else {
  186. return $this->handleRedirect($request, $response);
  187. }
  188. }
  189. /**
  190. * Connects to the remote server
  191. *
  192. * @return bool whether the connection can be persistent
  193. * @throws HTTP_Request2_Exception
  194. */
  195. protected function connect()
  196. {
  197. $secure = 0 == strcasecmp($this->request->getUrl()->getScheme(), 'https');
  198. $tunnel = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod();
  199. $headers = $this->request->getHeaders();
  200. $reqHost = $this->request->getUrl()->getHost();
  201. if (!($reqPort = $this->request->getUrl()->getPort())) {
  202. $reqPort = $secure? 443: 80;
  203. }
  204. if ($host = $this->request->getConfig('proxy_host')) {
  205. if (!($port = $this->request->getConfig('proxy_port'))) {
  206. throw new HTTP_Request2_LogicException(
  207. 'Proxy port not provided',
  208. HTTP_Request2_Exception::MISSING_VALUE
  209. );
  210. }
  211. $proxy = true;
  212. } else {
  213. $host = $reqHost;
  214. $port = $reqPort;
  215. $proxy = false;
  216. }
  217. if ($tunnel && !$proxy) {
  218. throw new HTTP_Request2_LogicException(
  219. "Trying to perform CONNECT request without proxy",
  220. HTTP_Request2_Exception::MISSING_VALUE
  221. );
  222. }
  223. if ($secure && !in_array('ssl', stream_get_transports())) {
  224. throw new HTTP_Request2_LogicException(
  225. 'Need OpenSSL support for https:// requests',
  226. HTTP_Request2_Exception::MISCONFIGURATION
  227. );
  228. }
  229. // RFC 2068, section 19.7.1: A client MUST NOT send the Keep-Alive
  230. // connection token to a proxy server...
  231. if ($proxy && !$secure &&
  232. !empty($headers['connection']) && 'Keep-Alive' == $headers['connection']
  233. ) {
  234. $this->request->setHeader('connection');
  235. }
  236. $keepAlive = ('1.1' == $this->request->getConfig('protocol_version') &&
  237. empty($headers['connection'])) ||
  238. (!empty($headers['connection']) &&
  239. 'Keep-Alive' == $headers['connection']);
  240. $host = ((!$secure || $proxy)? 'tcp://': 'ssl://') . $host;
  241. $options = array();
  242. if ($secure || $tunnel) {
  243. foreach ($this->request->getConfig() as $name => $value) {
  244. if ('ssl_' == substr($name, 0, 4) && null !== $value) {
  245. if ('ssl_verify_host' == $name) {
  246. if ($value) {
  247. $options['CN_match'] = $reqHost;
  248. }
  249. } else {
  250. $options[substr($name, 4)] = $value;
  251. }
  252. }
  253. }
  254. ksort($options);
  255. }
  256. // Changing SSL context options after connection is established does *not*
  257. // work, we need a new connection if options change
  258. $remote = $host . ':' . $port;
  259. $socketKey = $remote . (($secure && $proxy)? "->{$reqHost}:{$reqPort}": '') .
  260. (empty($options)? '': ':' . serialize($options));
  261. unset($this->socket);
  262. // We use persistent connections and have a connected socket?
  263. // Ensure that the socket is still connected, see bug #16149
  264. if ($keepAlive && !empty(self::$sockets[$socketKey]) &&
  265. !feof(self::$sockets[$socketKey])
  266. ) {
  267. $this->socket =& self::$sockets[$socketKey];
  268. } elseif ($secure && $proxy && !$tunnel) {
  269. $this->establishTunnel();
  270. $this->request->setLastEvent(
  271. 'connect', "ssl://{$reqHost}:{$reqPort} via {$host}:{$port}"
  272. );
  273. self::$sockets[$socketKey] =& $this->socket;
  274. } else {
  275. // Set SSL context options if doing HTTPS request or creating a tunnel
  276. $context = stream_context_create();
  277. foreach ($options as $name => $value) {
  278. if (!stream_context_set_option($context, 'ssl', $name, $value)) {
  279. throw new HTTP_Request2_LogicException(
  280. "Error setting SSL context option '{$name}'"
  281. );
  282. }
  283. }
  284. $track = @ini_set('track_errors', 1);
  285. $this->socket = @stream_socket_client(
  286. $remote, $errno, $errstr,
  287. $this->request->getConfig('connect_timeout'),
  288. STREAM_CLIENT_CONNECT, $context
  289. );
  290. if (!$this->socket) {
  291. $e = new HTTP_Request2_ConnectionException(
  292. "Unable to connect to {$remote}. Error: "
  293. . (empty($errstr)? $php_errormsg: $errstr), 0, $errno
  294. );
  295. }
  296. @ini_set('track_errors', $track);
  297. if (isset($e)) {
  298. throw $e;
  299. }
  300. $this->request->setLastEvent('connect', $remote);
  301. self::$sockets[$socketKey] =& $this->socket;
  302. }
  303. return $keepAlive;
  304. }
  305. /**
  306. * Establishes a tunnel to a secure remote server via HTTP CONNECT request
  307. *
  308. * This method will fail if 'ssl_verify_peer' is enabled. Probably because PHP
  309. * sees that we are connected to a proxy server (duh!) rather than the server
  310. * that presents its certificate.
  311. *
  312. * @link http://tools.ietf.org/html/rfc2817#section-5.2
  313. * @throws HTTP_Request2_Exception
  314. */
  315. protected function establishTunnel()
  316. {
  317. $donor = new self;
  318. $connect = new HTTP_Request2(
  319. $this->request->getUrl(), HTTP_Request2::METHOD_CONNECT,
  320. array_merge($this->request->getConfig(),
  321. array('adapter' => $donor))
  322. );
  323. $response = $connect->send();
  324. // Need any successful (2XX) response
  325. if (200 > $response->getStatus() || 300 <= $response->getStatus()) {
  326. throw new HTTP_Request2_ConnectionException(
  327. 'Failed to connect via HTTPS proxy. Proxy response: ' .
  328. $response->getStatus() . ' ' . $response->getReasonPhrase()
  329. );
  330. }
  331. $this->socket = $donor->socket;
  332. $modes = array(
  333. STREAM_CRYPTO_METHOD_TLS_CLIENT,
  334. STREAM_CRYPTO_METHOD_SSLv3_CLIENT,
  335. STREAM_CRYPTO_METHOD_SSLv23_CLIENT,
  336. STREAM_CRYPTO_METHOD_SSLv2_CLIENT
  337. );
  338. foreach ($modes as $mode) {
  339. if (stream_socket_enable_crypto($this->socket, true, $mode)) {
  340. return;
  341. }
  342. }
  343. throw new HTTP_Request2_ConnectionException(
  344. 'Failed to enable secure connection when connecting through proxy'
  345. );
  346. }
  347. /**
  348. * Checks whether current connection may be reused or should be closed
  349. *
  350. * @param boolean whether connection could be persistent
  351. * in the first place
  352. * @param HTTP_Request2_Response response object to check
  353. * @return boolean
  354. */
  355. protected function canKeepAlive($requestKeepAlive, HTTP_Request2_Response $response)
  356. {
  357. // Do not close socket on successful CONNECT request
  358. if (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() &&
  359. 200 <= $response->getStatus() && 300 > $response->getStatus()
  360. ) {
  361. return true;
  362. }
  363. $lengthKnown = 'chunked' == strtolower($response->getHeader('transfer-encoding'))
  364. || null !== $response->getHeader('content-length')
  365. // no body possible for such responses, see also request #17031
  366. || HTTP_Request2::METHOD_HEAD == $this->request->getMethod()
  367. || in_array($response->getStatus(), array(204, 304));
  368. $persistent = 'keep-alive' == strtolower($response->getHeader('connection')) ||
  369. (null === $response->getHeader('connection') &&
  370. '1.1' == $response->getVersion());
  371. return $requestKeepAlive && $lengthKnown && $persistent;
  372. }
  373. /**
  374. * Disconnects from the remote server
  375. */
  376. protected function disconnect()
  377. {
  378. if (is_resource($this->socket)) {
  379. fclose($this->socket);
  380. $this->socket = null;
  381. $this->request->setLastEvent('disconnect');
  382. }
  383. }
  384. /**
  385. * Handles HTTP redirection
  386. *
  387. * This method will throw an Exception if redirect to a non-HTTP(S) location
  388. * is attempted, also if number of redirects performed already is equal to
  389. * 'max_redirects' configuration parameter.
  390. *
  391. * @param HTTP_Request2 Original request
  392. * @param HTTP_Request2_Response Response containing redirect
  393. * @return HTTP_Request2_Response Response from a new location
  394. * @throws HTTP_Request2_Exception
  395. */
  396. protected function handleRedirect(HTTP_Request2 $request,
  397. HTTP_Request2_Response $response)
  398. {
  399. if (is_null($this->redirectCountdown)) {
  400. $this->redirectCountdown = $request->getConfig('max_redirects');
  401. }
  402. if (0 == $this->redirectCountdown) {
  403. $this->redirectCountdown = null;
  404. // Copying cURL behaviour
  405. throw new HTTP_Request2_MessageException (
  406. 'Maximum (' . $request->getConfig('max_redirects') . ') redirects followed',
  407. HTTP_Request2_Exception::TOO_MANY_REDIRECTS
  408. );
  409. }
  410. $redirectUrl = new Net_URL2(
  411. $response->getHeader('location'),
  412. array(Net_URL2::OPTION_USE_BRACKETS => $request->getConfig('use_brackets'))
  413. );
  414. // refuse non-HTTP redirect
  415. if ($redirectUrl->isAbsolute()
  416. && !in_array($redirectUrl->getScheme(), array('http', 'https'))
  417. ) {
  418. $this->redirectCountdown = null;
  419. throw new HTTP_Request2_MessageException(
  420. 'Refusing to redirect to a non-HTTP URL ' . $redirectUrl->__toString(),
  421. HTTP_Request2_Exception::NON_HTTP_REDIRECT
  422. );
  423. }
  424. // Theoretically URL should be absolute (see http://tools.ietf.org/html/rfc2616#section-14.30),
  425. // but in practice it is often not
  426. if (!$redirectUrl->isAbsolute()) {
  427. $redirectUrl = $request->getUrl()->resolve($redirectUrl);
  428. }
  429. $redirect = clone $request;
  430. $redirect->setUrl($redirectUrl);
  431. if (303 == $response->getStatus() || (!$request->getConfig('strict_redirects')
  432. && in_array($response->getStatus(), array(301, 302)))
  433. ) {
  434. $redirect->setMethod(HTTP_Request2::METHOD_GET);
  435. $redirect->setBody('');
  436. }
  437. if (0 < $this->redirectCountdown) {
  438. $this->redirectCountdown--;
  439. }
  440. return $this->sendRequest($redirect);
  441. }
  442. /**
  443. * Checks whether another request should be performed with server digest auth
  444. *
  445. * Several conditions should be satisfied for it to return true:
  446. * - response status should be 401
  447. * - auth credentials should be set in the request object
  448. * - response should contain WWW-Authenticate header with digest challenge
  449. * - there is either no challenge stored for this URL or new challenge
  450. * contains stale=true parameter (in other case we probably just failed
  451. * due to invalid username / password)
  452. *
  453. * The method stores challenge values in $challenges static property
  454. *
  455. * @param HTTP_Request2_Response response to check
  456. * @return boolean whether another request should be performed
  457. * @throws HTTP_Request2_Exception in case of unsupported challenge parameters
  458. */
  459. protected function shouldUseServerDigestAuth(HTTP_Request2_Response $response)
  460. {
  461. // no sense repeating a request if we don't have credentials
  462. if (401 != $response->getStatus() || !$this->request->getAuth()) {
  463. return false;
  464. }
  465. if (!$challenge = $this->parseDigestChallenge($response->getHeader('www-authenticate'))) {
  466. return false;
  467. }
  468. $url = $this->request->getUrl();
  469. $scheme = $url->getScheme();
  470. $host = $scheme . '://' . $url->getHost();
  471. if ($port = $url->getPort()) {
  472. if ((0 == strcasecmp($scheme, 'http') && 80 != $port) ||
  473. (0 == strcasecmp($scheme, 'https') && 443 != $port)
  474. ) {
  475. $host .= ':' . $port;
  476. }
  477. }
  478. if (!empty($challenge['domain'])) {
  479. $prefixes = array();
  480. foreach (preg_split('/\\s+/', $challenge['domain']) as $prefix) {
  481. // don't bother with different servers
  482. if ('/' == substr($prefix, 0, 1)) {
  483. $prefixes[] = $host . $prefix;
  484. }
  485. }
  486. }
  487. if (empty($prefixes)) {
  488. $prefixes = array($host . '/');
  489. }
  490. $ret = true;
  491. foreach ($prefixes as $prefix) {
  492. if (!empty(self::$challenges[$prefix]) &&
  493. (empty($challenge['stale']) || strcasecmp('true', $challenge['stale']))
  494. ) {
  495. // probably credentials are invalid
  496. $ret = false;
  497. }
  498. self::$challenges[$prefix] =& $challenge;
  499. }
  500. return $ret;
  501. }
  502. /**
  503. * Checks whether another request should be performed with proxy digest auth
  504. *
  505. * Several conditions should be satisfied for it to return true:
  506. * - response status should be 407
  507. * - proxy auth credentials should be set in the request object
  508. * - response should contain Proxy-Authenticate header with digest challenge
  509. * - there is either no challenge stored for this proxy or new challenge
  510. * contains stale=true parameter (in other case we probably just failed
  511. * due to invalid username / password)
  512. *
  513. * The method stores challenge values in $challenges static property
  514. *
  515. * @param HTTP_Request2_Response response to check
  516. * @return boolean whether another request should be performed
  517. * @throws HTTP_Request2_Exception in case of unsupported challenge parameters
  518. */
  519. protected function shouldUseProxyDigestAuth(HTTP_Request2_Response $response)
  520. {
  521. if (407 != $response->getStatus() || !$this->request->getConfig('proxy_user')) {
  522. return false;
  523. }
  524. if (!($challenge = $this->parseDigestChallenge($response->getHeader('proxy-authenticate')))) {
  525. return false;
  526. }
  527. $key = 'proxy://' . $this->request->getConfig('proxy_host') .
  528. ':' . $this->request->getConfig('proxy_port');
  529. if (!empty(self::$challenges[$key]) &&
  530. (empty($challenge['stale']) || strcasecmp('true', $challenge['stale']))
  531. ) {
  532. $ret = false;
  533. } else {
  534. $ret = true;
  535. }
  536. self::$challenges[$key] = $challenge;
  537. return $ret;
  538. }
  539. /**
  540. * Extracts digest method challenge from (WWW|Proxy)-Authenticate header value
  541. *
  542. * There is a problem with implementation of RFC 2617: several of the parameters
  543. * are defined as quoted-string there and thus may contain backslash escaped
  544. * double quotes (RFC 2616, section 2.2). However, RFC 2617 defines unq(X) as
  545. * just value of quoted-string X without surrounding quotes, it doesn't speak
  546. * about removing backslash escaping.
  547. *
  548. * Now realm parameter is user-defined and human-readable, strange things
  549. * happen when it contains quotes:
  550. * - Apache allows quotes in realm, but apparently uses realm value without
  551. * backslashes for digest computation
  552. * - Squid allows (manually escaped) quotes there, but it is impossible to
  553. * authorize with either escaped or unescaped quotes used in digest,
  554. * probably it can't parse the response (?)
  555. * - Both IE and Firefox display realm value with backslashes in
  556. * the password popup and apparently use the same value for digest
  557. *
  558. * HTTP_Request2 follows IE and Firefox (and hopefully RFC 2617) in
  559. * quoted-string handling, unfortunately that means failure to authorize
  560. * sometimes
  561. *
  562. * @param string value of WWW-Authenticate or Proxy-Authenticate header
  563. * @return mixed associative array with challenge parameters, false if
  564. * no challenge is present in header value
  565. * @throws HTTP_Request2_NotImplementedException in case of unsupported challenge parameters
  566. */
  567. protected function parseDigestChallenge($headerValue)
  568. {
  569. $authParam = '(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' .
  570. self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')';
  571. $challenge = "!(?<=^|\\s|,)Digest ({$authParam}\\s*(,\\s*|$))+!";
  572. if (!preg_match($challenge, $headerValue, $matches)) {
  573. return false;
  574. }
  575. preg_match_all('!' . $authParam . '!', $matches[0], $params);
  576. $paramsAry = array();
  577. $knownParams = array('realm', 'domain', 'nonce', 'opaque', 'stale',
  578. 'algorithm', 'qop');
  579. for ($i = 0; $i < count($params[0]); $i++) {
  580. // section 3.2.1: Any unrecognized directive MUST be ignored.
  581. if (in_array($params[1][$i], $knownParams)) {
  582. if ('"' == substr($params[2][$i], 0, 1)) {
  583. $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1);
  584. } else {
  585. $paramsAry[$params[1][$i]] = $params[2][$i];
  586. }
  587. }
  588. }
  589. // we only support qop=auth
  590. if (!empty($paramsAry['qop']) &&
  591. !in_array('auth', array_map('trim', explode(',', $paramsAry['qop'])))
  592. ) {
  593. throw new HTTP_Request2_NotImplementedException(
  594. "Only 'auth' qop is currently supported in digest authentication, " .
  595. "server requested '{$paramsAry['qop']}'"
  596. );
  597. }
  598. // we only support algorithm=MD5
  599. if (!empty($paramsAry['algorithm']) && 'MD5' != $paramsAry['algorithm']) {
  600. throw new HTTP_Request2_NotImplementedException(
  601. "Only 'MD5' algorithm is currently supported in digest authentication, " .
  602. "server requested '{$paramsAry['algorithm']}'"
  603. );
  604. }
  605. return $paramsAry;
  606. }
  607. /**
  608. * Parses [Proxy-]Authentication-Info header value and updates challenge
  609. *
  610. * @param array challenge to update
  611. * @param string value of [Proxy-]Authentication-Info header
  612. * @todo validate server rspauth response
  613. */
  614. protected function updateChallenge(&$challenge, $headerValue)
  615. {
  616. $authParam = '!(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' .
  617. self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')!';
  618. $paramsAry = array();
  619. preg_match_all($authParam, $headerValue, $params);
  620. for ($i = 0; $i < count($params[0]); $i++) {
  621. if ('"' == substr($params[2][$i], 0, 1)) {
  622. $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1);
  623. } else {
  624. $paramsAry[$params[1][$i]] = $params[2][$i];
  625. }
  626. }
  627. // for now, just update the nonce value
  628. if (!empty($paramsAry['nextnonce'])) {
  629. $challenge['nonce'] = $paramsAry['nextnonce'];
  630. $challenge['nc'] = 1;
  631. }
  632. }
  633. /**
  634. * Creates a value for [Proxy-]Authorization header when using digest authentication
  635. *
  636. * @param string user name
  637. * @param string password
  638. * @param string request URL
  639. * @param array digest challenge parameters
  640. * @return string value of [Proxy-]Authorization request header
  641. * @link http://tools.ietf.org/html/rfc2617#section-3.2.2
  642. */
  643. protected function createDigestResponse($user, $password, $url, &$challenge)
  644. {
  645. if (false !== ($q = strpos($url, '?')) &&
  646. $this->request->getConfig('digest_compat_ie')
  647. ) {
  648. $url = substr($url, 0, $q);
  649. }
  650. $a1 = md5($user . ':' . $challenge['realm'] . ':' . $password);
  651. $a2 = md5($this->request->getMethod() . ':' . $url);
  652. if (empty($challenge['qop'])) {
  653. $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $a2);
  654. } else {
  655. $challenge['cnonce'] = 'Req2.' . rand();
  656. if (empty($challenge['nc'])) {
  657. $challenge['nc'] = 1;
  658. }
  659. $nc = sprintf('%08x', $challenge['nc']++);
  660. $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $nc . ':' .
  661. $challenge['cnonce'] . ':auth:' . $a2);
  662. }
  663. return 'Digest username="' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $user) . '", ' .
  664. 'realm="' . $challenge['realm'] . '", ' .
  665. 'nonce="' . $challenge['nonce'] . '", ' .
  666. 'uri="' . $url . '", ' .
  667. 'response="' . $digest . '"' .
  668. (!empty($challenge['opaque'])?
  669. ', opaque="' . $challenge['opaque'] . '"':
  670. '') .
  671. (!empty($challenge['qop'])?
  672. ', qop="auth", nc=' . $nc . ', cnonce="' . $challenge['cnonce'] . '"':
  673. '');
  674. }
  675. /**
  676. * Adds 'Authorization' header (if needed) to request headers array
  677. *
  678. * @param array request headers
  679. * @param string request host (needed for digest authentication)
  680. * @param string request URL (needed for digest authentication)
  681. * @throws HTTP_Request2_NotImplementedException
  682. */
  683. protected function addAuthorizationHeader(&$headers, $requestHost, $requestUrl)
  684. {
  685. if (!($auth = $this->request->getAuth())) {
  686. return;
  687. }
  688. switch ($auth['scheme']) {
  689. case HTTP_Request2::AUTH_BASIC:
  690. $headers['authorization'] =
  691. 'Basic ' . base64_encode($auth['user'] . ':' . $auth['password']);
  692. break;
  693. case HTTP_Request2::AUTH_DIGEST:
  694. unset($this->serverChallenge);
  695. $fullUrl = ('/' == $requestUrl[0])?
  696. $this->request->getUrl()->getScheme() . '://' .
  697. $requestHost . $requestUrl:
  698. $requestUrl;
  699. foreach (array_keys(self::$challenges) as $key) {
  700. if ($key == substr($fullUrl, 0, strlen($key))) {
  701. $headers['authorization'] = $this->createDigestResponse(
  702. $auth['user'], $auth['password'],
  703. $requestUrl, self::$challenges[$key]
  704. );
  705. $this->serverChallenge =& self::$challenges[$key];
  706. break;
  707. }
  708. }
  709. break;
  710. default:
  711. throw new HTTP_Request2_NotImplementedException(
  712. "Unknown HTTP authentication scheme '{$auth['scheme']}'"
  713. );
  714. }
  715. }
  716. /**
  717. * Adds 'Proxy-Authorization' header (if needed) to request headers array
  718. *
  719. * @param array request headers
  720. * @param string request URL (needed for digest authentication)
  721. * @throws HTTP_Request2_NotImplementedException
  722. */
  723. protected function addProxyAuthorizationHeader(&$headers, $requestUrl)
  724. {
  725. if (!$this->request->getConfig('proxy_host') ||
  726. !($user = $this->request->getConfig('proxy_user')) ||
  727. (0 == strcasecmp('https', $this->request->getUrl()->getScheme()) &&
  728. HTTP_Request2::METHOD_CONNECT != $this->request->getMethod())
  729. ) {
  730. return;
  731. }
  732. $password = $this->request->getConfig('proxy_password');
  733. switch ($this->request->getConfig('proxy_auth_scheme')) {
  734. case HTTP_Request2::AUTH_BASIC:
  735. $headers['proxy-authorization'] =
  736. 'Basic ' . base64_encode($user . ':' . $password);
  737. break;
  738. case HTTP_Request2::AUTH_DIGEST:
  739. unset($this->proxyChallenge);
  740. $proxyUrl = 'proxy://' . $this->request->getConfig('proxy_host') .
  741. ':' . $this->request->getConfig('proxy_port');
  742. if (!empty(self::$challenges[$proxyUrl])) {
  743. $headers['proxy-authorization'] = $this->createDigestResponse(
  744. $user, $password,
  745. $requestUrl, self::$challenges[$proxyUrl]
  746. );
  747. $this->proxyChallenge =& self::$challenges[$proxyUrl];
  748. }
  749. break;
  750. default:
  751. throw new HTTP_Request2_NotImplementedException(
  752. "Unknown HTTP authentication scheme '" .
  753. $this->request->getConfig('proxy_auth_scheme') . "'"
  754. );
  755. }
  756. }
  757. /**
  758. * Creates the string with the Request-Line and request headers
  759. *
  760. * @return string
  761. * @throws HTTP_Request2_Exception
  762. */
  763. protected function prepareHeaders()
  764. {
  765. $headers = $this->request->getHeaders();
  766. $url = $this->request->getUrl();
  767. $connect = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod();
  768. $host = $url->getHost();
  769. $defaultPort = 0 == strcasecmp($url->getScheme(), 'https')? 443: 80;
  770. if (($port = $url->getPort()) && $port != $defaultPort || $connect) {
  771. $host .= ':' . (empty($port)? $defaultPort: $port);
  772. }
  773. // Do not overwrite explicitly set 'Host' header, see bug #16146
  774. if (!isset($headers['host'])) {
  775. $headers['host'] = $host;
  776. }
  777. if ($connect) {
  778. $requestUrl = $host;
  779. } else {
  780. if (!$this->request->getConfig('proxy_host') ||
  781. 0 == strcasecmp($url->getScheme(), 'https')
  782. ) {
  783. $requestUrl = '';
  784. } else {
  785. $requestUrl = $url->getScheme() . '://' . $host;
  786. }
  787. $path = $url->getPath();
  788. $query = $url->getQuery();
  789. $requestUrl .= (empty($path)? '/': $path) . (empty($query)? '': '?' . $query);
  790. }
  791. if ('1.1' == $this->request->getConfig('protocol_version') &&
  792. extension_loaded('zlib') && !isset($headers['accept-encoding'])
  793. ) {
  794. $headers['accept-encoding'] = 'gzip, deflate';
  795. }
  796. if (($jar = $this->request->getCookieJar())
  797. && ($cookies = $jar->getMatching($this->request->getUrl(), true))
  798. ) {
  799. $headers['cookie'] = (empty($headers['cookie'])? '': $headers['cookie'] . '; ') . $cookies;
  800. }
  801. $this->addAuthorizationHeader($headers, $host, $requestUrl);
  802. $this->addProxyAuthorizationHeader($headers, $requestUrl);
  803. $this->calculateRequestLength($headers);
  804. $headersStr = $this->request->getMethod() . ' ' . $requestUrl . ' HTTP/' .
  805. $this->request->getConfig('protocol_version') . "\r\n";
  806. foreach ($headers as $name => $value) {
  807. $canonicalName = implode('-', array_map('ucfirst', explode('-', $name)));
  808. $headersStr .= $canonicalName . ': ' . $value . "\r\n";
  809. }
  810. return $headersStr . "\r\n";
  811. }
  812. /**
  813. * Sends the request body
  814. *
  815. * @throws HTTP_Request2_MessageException
  816. */
  817. protected function writeBody()
  818. {
  819. if (in_array($this->request->getMethod(), self::$bodyDisallowed) ||
  820. 0 == $this->contentLength
  821. ) {
  822. return;
  823. }
  824. $position = 0;
  825. $bufferSize = $this->request->getConfig('buffer_size');
  826. while ($position < $this->contentLength) {
  827. if (is_string($this->requestBody)) {
  828. $str = substr($this->requestBody, $position, $bufferSize);
  829. } elseif (is_resource($this->requestBody)) {
  830. $str = fread($this->requestBody, $bufferSize);
  831. } else {
  832. $str = $this->requestBody->read($bufferSize);
  833. }
  834. if (false === @fwrite($this->socket, $str, strlen($str))) {
  835. throw new HTTP_Request2_MessageException('Error writing request');
  836. }
  837. // Provide the length of written string to the observer, request #7630
  838. $this->request->setLastEvent('sentBodyPart', strlen($str));
  839. $position += strlen($str);
  840. }
  841. $this->request->setLastEvent('sentBody', $this->contentLength);
  842. }
  843. /**
  844. * Reads the remote server's response
  845. *
  846. * @return HTTP_Request2_Response
  847. * @throws HTTP_Request2_Exception
  848. */
  849. protected function readResponse()
  850. {
  851. $bufferSize = $this->request->getConfig('buffer_size');
  852. do {
  853. $response = new HTTP_Request2_Response(
  854. $this->readLine($bufferSize), true, $this->request->getUrl()
  855. );
  856. do {
  857. $headerLine = $this->readLine($bufferSize);
  858. $response->parseHeaderLine($headerLine);
  859. } while ('' != $headerLine);
  860. } while (in_array($response->getStatus(), array(100, 101)));
  861. $this->request->setLastEvent('receivedHeaders', $response);
  862. // No body possible in such responses
  863. if (HTTP_Request2::METHOD_HEAD == $this->request->getMethod() ||
  864. (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod() &&
  865. 200 <= $response->getStatus() && 300 > $response->getStatus()) ||
  866. in_array($response->getStatus(), array(204, 304))
  867. ) {
  868. return $response;
  869. }
  870. $chunked = 'chunked' == $response->getHeader('transfer-encoding');
  871. $length = $response->getHeader('content-length');
  872. $hasBody = false;
  873. if ($chunked || null === $length || 0 < intval($length)) {
  874. // RFC 2616, section 4.4:
  875. // 3. ... If a message is received with both a
  876. // Transfer-Encoding header field and a Content-Length header field,
  877. // the latter MUST be ignored.
  878. $toRead = ($chunked || null === $length)? null: $length;
  879. $this->chunkLength = 0;
  880. while (!feof($this->socket) && (is_null($toRead) || 0 < $toRead)) {
  881. if ($chunked) {
  882. $data = $this->readChunked($bufferSize);
  883. } elseif (is_null($toRead)) {
  884. $data = $this->fread($bufferSize);
  885. } else {
  886. $data = $this->fread(min($toRead, $bufferSize));
  887. $toRead -= strlen($data);
  888. }
  889. if ('' == $data && (!$this->chunkLength || feof($this->socket))) {
  890. break;
  891. }
  892. $hasBody = true;
  893. if ($this->request->getConfig('store_body')) {
  894. $response->appendBody($data);
  895. }
  896. if (!in_array($response->getHeader('content-encoding'), array('identity', null))) {
  897. $this->request->setLastEvent('receivedEncodedBodyPart', $data);
  898. } else {
  899. $this->request->setLastEvent('receivedBodyPart', $data);
  900. }
  901. }
  902. }
  903. if ($hasBody) {
  904. $this->request->setLastEvent('receivedBody', $response);
  905. }
  906. return $response;
  907. }
  908. /**
  909. * Reads until either the end of the socket or a newline, whichever comes first
  910. *
  911. * Strips the trailing newline from the returned data, handles global
  912. * request timeout. Method idea borrowed from Net_Socket PEAR package.
  913. *
  914. * @param int buffer size to use for reading
  915. * @return Available data up to the newline (not including newline)
  916. * @throws HTTP_Request2_MessageException In case of timeout
  917. */
  918. protected function readLine($bufferSize)
  919. {
  920. $line = '';
  921. while (!feof($this->socket)) {
  922. if ($this->deadline) {
  923. stream_set_timeout($this->socket, max($this->deadline - time(), 1));
  924. }
  925. $line .= @fgets($this->socket, $bufferSize);
  926. $info = stream_get_meta_data($this->socket);
  927. if ($info['timed_out'] || $this->deadline && time() > $this->deadline) {
  928. $reason = $this->deadline
  929. ? 'after ' . $this->request->getConfig('timeout') . ' second(s)'
  930. : 'due to default_socket_timeout php.ini setting';
  931. throw new HTTP_Request2_MessageException(
  932. "Request timed out {$reason}", HTTP_Request2_Exception::TIMEOUT
  933. );
  934. }
  935. if (substr($line, -1) == "\n") {
  936. return rtrim($line, "\r\n");
  937. }
  938. }
  939. return $line;
  940. }
  941. /**
  942. * Wrapper around fread(), handles global request timeout
  943. *
  944. * @param int Reads up to this number of bytes
  945. * @return Data read from socket
  946. * @throws HTTP_Request2_MessageException In case of timeout
  947. */
  948. protected function fread($length)
  949. {
  950. if ($this->deadline) {
  951. stream_set_timeout($this->socket, max($this->deadline - time(), 1));
  952. }
  953. $data = fread($this->socket, $length);
  954. $info = stream_get_meta_data($this->socket);
  955. if ($info['timed_out'] || $this->deadline && time() > $this->deadline) {
  956. $reason = $this->deadline
  957. ? 'after ' . $this->request->getConfig('timeout') . ' second(s)'
  958. : 'due to default_socket_timeout php.ini setting';
  959. throw new HTTP_Request2_MessageException(
  960. "Request timed out {$reason}", HTTP_Request2_Exception::TIMEOUT
  961. );
  962. }
  963. return $data;
  964. }
  965. /**
  966. * Reads a part of response body encoded with chunked Transfer-Encoding
  967. *
  968. * @param int buffer size to use for reading
  969. * @return string
  970. * @throws HTTP_Request2_MessageException
  971. */
  972. protected function readChunked($bufferSize)
  973. {
  974. // at start of the next chunk?
  975. if (0 == $this->chunkLength) {
  976. $line = $this->readLine($bufferSize);
  977. if (!preg_match('/^([0-9a-f]+)/i', $line, $matches)) {
  978. throw new HTTP_Request2_MessageException(
  979. "Cannot decode chunked response, invalid chunk length '{$line}'",
  980. HTTP_Request2_Exception::DECODE_ERROR
  981. );
  982. } else {
  983. $this->chunkLength = hexdec($matches[1]);
  984. // Chunk with zero length indicates the end
  985. if (0 == $this->chunkLength) {
  986. $this->readLine($bufferSize);
  987. return '';
  988. }
  989. }
  990. }
  991. $data = $this->fread(min($this->chunkLength, $bufferSize));
  992. $this->chunkLength -= strlen($data);
  993. if (0 == $this->chunkLength) {
  994. $this->readLine($bufferSize); // Trailing CRLF
  995. }
  996. return $data;
  997. }
  998. }
  999. ?>