From e7eb9f35100eaad354530ac7717dae76183fa457 Mon Sep 17 00:00:00 2001 From: ozh Date: Sun, 30 Nov 2014 11:49:25 +0100 Subject: [PATCH] Sync with recent Requests commits. Fixes #1796 --- includes/Requests/Requests.php | 44 ++-- includes/Requests/Requests/Cookie.php | 207 +++++++++++++++++- includes/Requests/Requests/Cookie/Jar.php | 18 +- includes/Requests/Requests/IRI.php | 7 +- includes/Requests/Requests/Session.php | 11 +- includes/Requests/Requests/Transport/cURL.php | 43 +++- .../Requests/Requests/Transport/fsockopen.php | 31 ++- 7 files changed, 317 insertions(+), 44 deletions(-) diff --git a/includes/Requests/Requests.php b/includes/Requests/Requests.php index b24e6d9..31eee40 100644 --- a/includes/Requests/Requests.php +++ b/includes/Requests/Requests.php @@ -81,9 +81,9 @@ class Requests { * * Use {@see get_transport()} instead * - * @var string|null + * @var array */ - public static $transport = null; + public static $transport = array(); /** * This is a static class, do not instantiate it @@ -147,11 +147,16 @@ public static function add_transport($transport) { * @throws Requests_Exception If no valid transport is found (`notransport`) * @return Requests_Transport */ - protected static function get_transport() { + protected static function get_transport($capabilities = array()) { // Caching code, don't bother testing coverage // @codeCoverageIgnoreStart - if (self::$transport !== null) { - return new self::$transport(); + // array of capabilities as a string to be used as an array key + ksort($capabilities); + $cap_string = serialize($capabilities); + + // Don't search for a transport if it's already been done for these $capabilities + if (isset(self::$transport[$cap_string]) && self::$transport[$cap_string] !== null) { + return new self::$transport[$cap_string](); } // @codeCoverageIgnoreEnd @@ -167,17 +172,17 @@ protected static function get_transport() { if (!class_exists($class)) continue; - $result = call_user_func(array($class, 'test')); + $result = call_user_func(array($class, 'test'), $capabilities); if ($result) { - self::$transport = $class; + self::$transport[$cap_string] = $class; break; } } - if (self::$transport === null) { + if (self::$transport[$cap_string] === null) { throw new Requests_Exception('No working transports found', 'notransport', self::$transports); } - - return new self::$transport(); + + return new self::$transport[$cap_string](); } /**#@+ @@ -253,7 +258,9 @@ public static function patch($url, $headers, $data = array(), $options = array() * options: * * - `timeout`: How long should we wait for a response? - * (integer, seconds, default: 10) + * (float, seconds with a millisecond precision, default: 10, example: 0.01) + * - `connect_timeout`: How long should we wait while trying to connect? + * (float, seconds with a millisecond precision, default: 10, example: 0.01) * - `useragent`: Useragent to send to the server * (string, default: php-requests/$version) * - `follow_redirects`: Should we follow 3xx redirects? @@ -310,9 +317,10 @@ public static function request($url, $headers = array(), $data = array(), $type if (is_string($options['transport'])) { $transport = new $transport(); } - } - else { - $transport = self::get_transport(); + } else { + $need_ssl = (0 === stripos($url, 'https://')); + $capabilities = array('ssl' => $need_ssl); + $transport = self::get_transport($capabilities); } $response = $transport->request($url, $headers, $data, $options); @@ -343,9 +351,6 @@ public static function request($url, $headers = array(), $data = array(), $type * - `type`: HTTP request type (use Requests constants). Same as the `$type` * parameter to {@see Requests::request} * (string, default: `Requests::GET`) - * - `data`: Associative array of options. Same as the `$options` parameter - * to {@see Requests::request} - * (array, default: see {@see Requests::request}) * - `cookies`: Associative array of cookie name to value, or cookie jar. * (array|Requests_Cookie_Jar) * @@ -443,6 +448,7 @@ public static function request_multiple($requests, $options = array()) { protected static function get_default_options($multirequest = false) { $defaults = array( 'timeout' => 10, + 'connect_timeout' => 10, 'useragent' => 'php-requests/' . self::VERSION, 'redirected' => 0, 'redirects' => 10, @@ -476,7 +482,7 @@ protected static function get_default_options($multirequest = false) { * @return array $options */ protected static function set_defaults(&$url, &$headers, &$data, &$type, &$options) { - if (!preg_match('/^http(s)?:\/\//i', $url)) { + if (!preg_match('/^http(s)?:\/\//i', $url, $matches)) { throw new Requests_Exception('Only HTTP requests are handled.', 'nonhttp', $url); } @@ -592,7 +598,7 @@ protected static function parse_response($headers, $url, $req_headers, $req_data } $options['redirected']++; $location = $return->headers['location']; - if (strpos ($location, '/') === 0) { + if (strpos ($location, 'http://') !== 0 && strpos ($location, 'https://') !== 0) { // relative redirect, for compatibility make it absolute $location = Requests_IRI::absolutize($url, $location); $location = $location->uri; diff --git a/includes/Requests/Requests/Cookie.php b/includes/Requests/Requests/Cookie.php index 365fad8..adfbdbb 100644 --- a/includes/Requests/Requests/Cookie.php +++ b/includes/Requests/Requests/Cookie.php @@ -34,6 +34,16 @@ class Requests_Cookie { */ public $attributes = array(); + /** + * Cookie flags + * + * Valid keys are (currently) creation, last-access, persistent and + * host-only. + * + * @var array + */ + public $flags = array(); + /** * Create a new cookie object * @@ -41,10 +51,162 @@ class Requests_Cookie { * @param string $value * @param array $attributes Associative array of attribute data */ - public function __construct($name, $value, $attributes = array()) { + public function __construct($name, $value, $attributes = array(), $flags = array()) { $this->name = $name; $this->value = $value; $this->attributes = $attributes; + $default_flags = array( + 'creation' => time(), + 'last-access' => time(), + 'persistent' => false, + 'host-only' => true, + ); + $this->flags = array_merge($default_flags, $flags); + + $this->normalize(); + } + + /** + * Check if a cookie is valid for a given URI + * + * @param Requests_IRI $uri URI to check + * @return boolean Whether the cookie is valid for the given URI + */ + public function uriMatches(Requests_IRI $uri) { + if (!$this->domainMatches($uri->host)) { + return false; + } + + if (!$this->pathMatches($uri->path)) { + return false; + } + + if (!empty($this->attributes['secure']) && $uri->scheme !== 'https') { + return false; + } + + return true; + } + + /** + * Check if a cookie is valid for a given domain + * + * @param string $string Domain to check + * @return boolean Whether the cookie is valid for the given domain + */ + public function domainMatches($string) { + if (!isset($this->attributes['domain'])) { + // Cookies created manually; cookies created by Requests will set + // the domain to the requested domain + return true; + } + + $domain_string = $this->attributes['domain']; + if ($domain_string === $string) { + // The domain string and the string are identical. + return true; + } + + // If the cookie is marked as host-only and we don't have an exact + // match, reject the cookie + if ($this->flags['host-only'] === true) { + return false; + } + + if (strlen($string) <= $domain_string) { + // For obvious reasons, the string cannot be a suffix if the domain + // is shorter than the domain string + return false; + } + + if (substr($string, -1 * strlen($domain_string)) !== $domain_string) { + // The domain string should be a suffix of the string. + return false; + } + + $prefix = substr($string, 0, strlen($string) - strlen($domain_string)); + if (substr($prefix, -1) !== '.') { + // The last character of the string that is not included in the + // domain string should be a %x2E (".") character. + return false; + } + + if (preg_match('#^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $string)) { + // The string should be a host name (i.e., not an IP address). + return false; + } + + return true; + } + + /** + * Check if a cookie is valid for a given path + * + * From the path-match check in RFC 6265 section 5.1.4 + * + * @param string $request_path Path to check + * @return boolean Whether the cookie is valid for the given path + */ + public function pathMatches($request_path) { + if (empty($request_path)) { + // Normalize empty path to root + $request_path = '/'; + } + + if (!isset($this->attributes['path'])) { + // Cookies created manually; cookies created by Requests will set + // the path to the requested path + return true; + } + + $cookie_path = $this->attributes['path']; + + if ($cookie_path === $request_path) { + // The cookie-path and the request-path are identical. + return true; + } + + if (strlen($request_path) > strlen($cookie_path) && substr($request_path, 0, strlen($cookie_path)) === $cookie_path) { + if (substr($cookie_path, -1) === '/') { + // The cookie-path is a prefix of the request-path, and the last + // character of the cookie-path is %x2F ("/"). + return true; + } + + if (substr($request_path, strlen($cookie_path), 1) === '/') { + // The cookie-path is a prefix of the request-path, and the + // first character of the request-path that is not included in + // the cookie-path is a %x2F ("/") character. + return true; + } + } + + return false; + } + + /** + * Normalize cookie and attributes + * + * @return boolean Whether the cookie was successfully normalized + */ + public function normalize() { + foreach ($this->attributes as $key => $value) { + $orig_value = $value; + switch ($key) { + case 'domain': + // Domain normalization, as per RFC 6265 section 5.2.3 + if ($value[0] === '.') { + $value = substr($value, 1); + } + break; + } + + if ($value !== $orig_value) { + $this->attributes[$key] = $value; + } + } + + return true; } /** @@ -154,7 +316,7 @@ public static function parse($string, $name = '') { * @param Requests_Response_Headers $headers * @return array */ - public static function parseFromHeaders(Requests_Response_Headers $headers) { + public static function parseFromHeaders(Requests_Response_Headers $headers, Requests_IRI $origin = null) { $cookie_headers = $headers->getValues('Set-Cookie'); if (empty($cookie_headers)) { return array(); @@ -163,6 +325,47 @@ public static function parseFromHeaders(Requests_Response_Headers $headers) { $cookies = array(); foreach ($cookie_headers as $header) { $parsed = self::parse($header); + + // Default domain/path attributes + if (empty($parsed->attributes['domain']) && !empty($origin)) { + $parsed->attributes['domain'] = $origin->host; + $parsed->flags['host-only'] = false; + } + else { + $parsed->flags['host-only'] = true; + } + + $path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/'); + if (!$path_is_valid && !empty($origin)) { + $path = $origin->path; + + // Default path normalization as per RFC 6265 section 5.1.4 + if (substr($path, 0, 1) !== '/') { + // If the uri-path is empty or if the first character of + // the uri-path is not a %x2F ("/") character, output + // %x2F ("/") and skip the remaining steps. + $path = '/'; + } + elseif (substr_count($path, '/') === 1) { + // If the uri-path contains no more than one %x2F ("/") + // character, output %x2F ("/") and skip the remaining + // step. + $path = '/'; + } + else { + // Output the characters of the uri-path from the first + // character up to, but not including, the right-most + // %x2F ("/"). + $path = substr($path, 0, strrpos($path, '/')); + } + $parsed->attributes['path'] = $path; + } + + // Reject invalid cookie domains + if (!$parsed->domainMatches($origin->host)) { + continue; + } + $cookies[$parsed->name] = $parsed; } diff --git a/includes/Requests/Requests/Cookie/Jar.php b/includes/Requests/Requests/Cookie/Jar.php index 6d2f53f..82c332d 100644 --- a/includes/Requests/Requests/Cookie/Jar.php +++ b/includes/Requests/Requests/Cookie/Jar.php @@ -121,12 +121,19 @@ public function register(Requests_Hooker $hooks) { * @param string $type * @param array $options */ - public function before_request(&$url, &$headers, &$data, &$type, &$options) { + public function before_request($url, &$headers, &$data, &$type, &$options) { + if ( ! $url instanceof Requests_IRI ) { + $url = new Requests_IRI($url); + } + if (!empty($this->cookies)) { $cookies = array(); foreach ($this->cookies as $key => $cookie) { $cookie = $this->normalizeCookie($cookie, $key); - $cookies[] = $cookie->formatForHeader(); + + if ( $cookie->domainMatches( $url->host ) ) { + $cookies[] = $cookie->formatForHeader(); + } } $headers['Cookie'] = implode('; ', $cookies); @@ -139,7 +146,12 @@ public function before_request(&$url, &$headers, &$data, &$type, &$options) { * @var Requests_Response $response */ public function before_redirect_check(Requests_Response &$return) { - $cookies = Requests_Cookie::parseFromHeaders($return->headers); + $url = $return->url; + if ( ! $url instanceof Requests_IRI ) { + $url = new Requests_IRI($url); + } + + $cookies = Requests_Cookie::parseFromHeaders($return->headers, $url); $this->cookies = array_merge($this->cookies, $cookies); $return->cookies = $this; } diff --git a/includes/Requests/Requests/IRI.php b/includes/Requests/Requests/IRI.php index b8dceae..26f215b 100644 --- a/includes/Requests/Requests/IRI.php +++ b/includes/Requests/Requests/IRI.php @@ -115,11 +115,9 @@ class Requests_IRI ), 'http' => array( 'port' => 80, - 'ipath' => '/' ), 'https' => array( 'port' => 443, - 'ipath' => '/' ), ); @@ -743,6 +741,10 @@ protected function scheme_normalization() { $this->ipath = ''; } + if (isset($this->ihost) && empty($this->ipath)) + { + $this->ipath = '/'; + } if (isset($this->normalization[$this->scheme]['iquery']) && $this->iquery === $this->normalization[$this->scheme]['iquery']) { $this->iquery = null; @@ -1060,7 +1062,6 @@ protected function set_path($ipath) $cache[$ipath] = array($valid, $removed); $this->ipath = ($this->scheme !== null) ? $removed : $valid; } - $this->scheme_normalization(); return true; } diff --git a/includes/Requests/Requests/Session.php b/includes/Requests/Requests/Session.php index bdcb1dd..f519d8d 100644 --- a/includes/Requests/Requests/Session.php +++ b/includes/Requests/Requests/Session.php @@ -81,8 +81,9 @@ public function __construct($url = null, $headers = array(), $data = array(), $o * @return mixed|null Property value, null if none found */ public function __get($key) { - if (isset($this->options[$key])) + if (isset($this->options[$key])) { return $this->options[$key]; + } return null; } @@ -112,7 +113,9 @@ public function __isset($key) { * @param string $key Property key */ public function __unset($key) { - $this->options[$key] = null; + if (isset($this->options[$key])) { + unset($this->options[$key]); + } } /**#@+ @@ -236,6 +239,7 @@ protected function merge_request($request, $merge_options = true) { $request['url'] = Requests_IRI::absolutize($this->url, $request['url']); $request['url'] = $request['url']->uri; } + $request['headers'] = array_merge($this->headers, $request['headers']); if (is_array($request['data']) && is_array($this->data)) { @@ -248,6 +252,7 @@ protected function merge_request($request, $merge_options = true) { // Disallow forcing the type, as that's a per request setting unset($request['options']['type']); } + return $request; } -} \ No newline at end of file +} diff --git a/includes/Requests/Requests/Transport/cURL.php b/includes/Requests/Requests/Transport/cURL.php index ad71fbf..678ab73 100644 --- a/includes/Requests/Requests/Transport/cURL.php +++ b/includes/Requests/Requests/Transport/cURL.php @@ -13,6 +13,9 @@ * @subpackage Transport */ class Requests_Transport_cURL implements Requests_Transport { + const CURL_7_10_5 = 0x070A05; + const CURL_7_16_2 = 0x071002; + /** * Raw HTTP data * @@ -30,7 +33,7 @@ class Requests_Transport_cURL implements Requests_Transport { /** * Version string * - * @var string + * @var long */ public $version; @@ -60,17 +63,20 @@ class Requests_Transport_cURL implements Requests_Transport { */ public function __construct() { $curl = curl_version(); - $this->version = $curl['version']; + $this->version = $curl['version_number']; $this->fp = curl_init(); curl_setopt($this->fp, CURLOPT_HEADER, false); curl_setopt($this->fp, CURLOPT_RETURNTRANSFER, 1); - if (version_compare($this->version, '7.10.5', '>=')) { + if ($this->version >= self::CURL_7_10_5) { curl_setopt($this->fp, CURLOPT_ENCODING, ''); } - if (version_compare($this->version, '7.19.4', '>=')) { + if (defined('CURLOPT_PROTOCOLS')) { curl_setopt($this->fp, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); } + if (defined('CURLOPT_REDIR_PROTOCOLS')) { + curl_setopt($this->fp, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + } } /** @@ -118,6 +124,7 @@ public function request($url, $headers = array(), $data = array(), $options = ar } $this->process_response($response, $options); + curl_close($this->fp); return $this->headers; } @@ -246,9 +253,17 @@ protected function setup_handle($url, $headers, $data, $options) { break; } + if( is_int($options['timeout']) or $this->version < self::CURL_7_16_2 ) { + curl_setopt($this->fp, CURLOPT_TIMEOUT, ceil($options['timeout'])); + } else { + curl_setopt($this->fp, CURLOPT_TIMEOUT_MS, round($options['timeout'] * 1000) ); + } + if( is_int($options['connect_timeout']) or $this->version < self::CURL_7_16_2 ) { + curl_setopt($this->fp, CURLOPT_CONNECTTIMEOUT, ceil($options['connect_timeout'])); + } else { + curl_setopt($this->fp, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000)); + } curl_setopt($this->fp, CURLOPT_URL, $url); - curl_setopt($this->fp, CURLOPT_TIMEOUT, $options['timeout']); - curl_setopt($this->fp, CURLOPT_CONNECTTIMEOUT, $options['timeout']); curl_setopt($this->fp, CURLOPT_REFERER, $url); curl_setopt($this->fp, CURLOPT_USERAGENT, $options['useragent']); curl_setopt($this->fp, CURLOPT_HTTPHEADER, $headers); @@ -260,7 +275,6 @@ protected function setup_handle($url, $headers, $data, $options) { public function process_response($response, $options) { if ($options['blocking'] === false) { - curl_close($this->fp); $fake_headers = ''; $options['hooks']->dispatch('curl.after_request', array(&$fake_headers)); return false; @@ -279,7 +293,6 @@ public function process_response($response, $options) { } $this->info = curl_getinfo($this->fp); - curl_close($this->fp); $options['hooks']->dispatch('curl.after_request', array(&$this->headers)); return $this->headers; } @@ -343,7 +356,17 @@ protected static function format_get($url, $data) { * @codeCoverageIgnore * @return boolean True if the transport is valid, false otherwise. */ - public static function test() { - return (function_exists('curl_init') && function_exists('curl_exec')); + public static function test($capabilities = array()) { + if (!function_exists('curl_init') && !function_exists('curl_exec')) + return false; + + // If needed, check that our installed curl version supports SSL + if (isset( $capabilities['ssl'] ) && $capabilities['ssl']) { + $curl_version = curl_version(); + if (!(CURL_VERSION_SSL & $curl_version['features'])) + return false; + } + + return true; } } diff --git a/includes/Requests/Requests/Transport/fsockopen.php b/includes/Requests/Requests/Transport/fsockopen.php index 99c1275..8975e22 100644 --- a/includes/Requests/Requests/Transport/fsockopen.php +++ b/includes/Requests/Requests/Transport/fsockopen.php @@ -13,6 +13,13 @@ * @subpackage Transport */ class Requests_Transport_fsockopen implements Requests_Transport { + /** + * Second to microsecond conversion + * + * @var integer + */ + const SECOND_IN_MICROSECONDS = 1000000; + /** * Raw HTTP data * @@ -99,7 +106,7 @@ public function request($url, $headers = array(), $data = array(), $options = ar $options['hooks']->dispatch('fsockopen.remote_socket', array(&$remote_socket)); - $fp = stream_socket_client($remote_socket, $errno, $errstr, $options['timeout'], STREAM_CLIENT_CONNECT, $context); + $fp = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context); restore_error_handler(); @@ -198,7 +205,10 @@ public function request($url, $headers = array(), $data = array(), $options = ar $options['hooks']->dispatch('fsockopen.after_request', array(&$fake_headers)); return ''; } - stream_set_timeout($fp, $options['timeout']); + + $timeout_sec = (int) floor($options['timeout']); + $timeout_msec = $timeout_sec == $options['timeout'] ? 0 : self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS; + stream_set_timeout($fp, $timeout_sec, $timeout_msec); $this->info = stream_get_meta_data($fp); @@ -375,7 +385,20 @@ public function verify_certificate_from_context($host, $context) { * @codeCoverageIgnore * @return boolean True if the transport is valid, false otherwise. */ - public static function test() { - return function_exists('fsockopen'); + public static function test($capabilities = array()) { + if (!function_exists('fsockopen')) + return false; + + // If needed, check that streams support SSL + if (isset( $capabilities['ssl'] ) && $capabilities['ssl']) { + if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) + return false; + + // Currently broken, thanks to https://github.com/facebook/hhvm/issues/2156 + if (defined('HHVM_VERSION')) + return false; + } + + return true; } } -- 2.45.0