From a415c29efee45520ae252d2aa28f1083a521cd7b Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 17 Apr 2024 09:56:49 +0200 Subject: Adding upstream version 6.4.3+dfsg1. Signed-off-by: Daniel Baumann --- wp-includes/Requests/library/Requests.php | 12 + wp-includes/Requests/src/Auth.php | 36 + wp-includes/Requests/src/Auth/Basic.php | 103 ++ wp-includes/Requests/src/Autoload.php | 187 ++++ wp-includes/Requests/src/Capability.php | 36 + wp-includes/Requests/src/Cookie.php | 527 +++++++++ wp-includes/Requests/src/Cookie/Jar.php | 187 ++++ wp-includes/Requests/src/Exception.php | 66 ++ .../Requests/src/Exception/ArgumentCount.php | 47 + wp-includes/Requests/src/Exception/Http.php | 78 ++ .../Requests/src/Exception/Http/Status304.php | 31 + .../Requests/src/Exception/Http/Status305.php | 31 + .../Requests/src/Exception/Http/Status306.php | 31 + .../Requests/src/Exception/Http/Status400.php | 31 + .../Requests/src/Exception/Http/Status401.php | 31 + .../Requests/src/Exception/Http/Status402.php | 31 + .../Requests/src/Exception/Http/Status403.php | 31 + .../Requests/src/Exception/Http/Status404.php | 31 + .../Requests/src/Exception/Http/Status405.php | 31 + .../Requests/src/Exception/Http/Status406.php | 31 + .../Requests/src/Exception/Http/Status407.php | 31 + .../Requests/src/Exception/Http/Status408.php | 31 + .../Requests/src/Exception/Http/Status409.php | 31 + .../Requests/src/Exception/Http/Status410.php | 31 + .../Requests/src/Exception/Http/Status411.php | 31 + .../Requests/src/Exception/Http/Status412.php | 31 + .../Requests/src/Exception/Http/Status413.php | 31 + .../Requests/src/Exception/Http/Status414.php | 31 + .../Requests/src/Exception/Http/Status415.php | 31 + .../Requests/src/Exception/Http/Status416.php | 31 + .../Requests/src/Exception/Http/Status417.php | 31 + .../Requests/src/Exception/Http/Status418.php | 35 + .../Requests/src/Exception/Http/Status428.php | 35 + .../Requests/src/Exception/Http/Status429.php | 35 + .../Requests/src/Exception/Http/Status431.php | 35 + .../Requests/src/Exception/Http/Status500.php | 31 + .../Requests/src/Exception/Http/Status501.php | 31 + .../Requests/src/Exception/Http/Status502.php | 31 + .../Requests/src/Exception/Http/Status503.php | 31 + .../Requests/src/Exception/Http/Status504.php | 31 + .../Requests/src/Exception/Http/Status505.php | 31 + .../Requests/src/Exception/Http/Status511.php | 35 + .../Requests/src/Exception/Http/StatusUnknown.php | 49 + .../Requests/src/Exception/InvalidArgument.php | 41 + wp-includes/Requests/src/Exception/Transport.php | 17 + .../Requests/src/Exception/Transport/Curl.php | 80 ++ wp-includes/Requests/src/HookManager.php | 33 + wp-includes/Requests/src/Hooks.php | 103 ++ wp-includes/Requests/src/IdnaEncoder.php | 412 ++++++++ wp-includes/Requests/src/Ipv6.php | 203 ++++ wp-includes/Requests/src/Iri.php | 1116 ++++++++++++++++++++ wp-includes/Requests/src/Port.php | 75 ++ wp-includes/Requests/src/Proxy.php | 38 + wp-includes/Requests/src/Proxy/Http.php | 164 +++ wp-includes/Requests/src/Requests.php | 1099 +++++++++++++++++++ wp-includes/Requests/src/Response.php | 165 +++ wp-includes/Requests/src/Response/Headers.php | 127 +++ wp-includes/Requests/src/Session.php | 308 ++++++ wp-includes/Requests/src/Ssl.php | 182 ++++ wp-includes/Requests/src/Transport.php | 45 + wp-includes/Requests/src/Transport/Curl.php | 640 +++++++++++ wp-includes/Requests/src/Transport/Fsockopen.php | 510 +++++++++ .../src/Utility/CaseInsensitiveDictionary.php | 127 +++ .../Requests/src/Utility/FilteredIterator.php | 97 ++ .../Requests/src/Utility/InputValidator.php | 109 ++ 65 files changed, 8031 insertions(+) create mode 100644 wp-includes/Requests/library/Requests.php create mode 100644 wp-includes/Requests/src/Auth.php create mode 100644 wp-includes/Requests/src/Auth/Basic.php create mode 100644 wp-includes/Requests/src/Autoload.php create mode 100644 wp-includes/Requests/src/Capability.php create mode 100644 wp-includes/Requests/src/Cookie.php create mode 100644 wp-includes/Requests/src/Cookie/Jar.php create mode 100644 wp-includes/Requests/src/Exception.php create mode 100644 wp-includes/Requests/src/Exception/ArgumentCount.php create mode 100644 wp-includes/Requests/src/Exception/Http.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status304.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status305.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status306.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status400.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status401.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status402.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status403.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status404.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status405.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status406.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status407.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status408.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status409.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status410.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status411.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status412.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status413.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status414.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status415.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status416.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status417.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status418.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status428.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status429.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status431.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status500.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status501.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status502.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status503.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status504.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status505.php create mode 100644 wp-includes/Requests/src/Exception/Http/Status511.php create mode 100644 wp-includes/Requests/src/Exception/Http/StatusUnknown.php create mode 100644 wp-includes/Requests/src/Exception/InvalidArgument.php create mode 100644 wp-includes/Requests/src/Exception/Transport.php create mode 100644 wp-includes/Requests/src/Exception/Transport/Curl.php create mode 100644 wp-includes/Requests/src/HookManager.php create mode 100644 wp-includes/Requests/src/Hooks.php create mode 100644 wp-includes/Requests/src/IdnaEncoder.php create mode 100644 wp-includes/Requests/src/Ipv6.php create mode 100644 wp-includes/Requests/src/Iri.php create mode 100644 wp-includes/Requests/src/Port.php create mode 100644 wp-includes/Requests/src/Proxy.php create mode 100644 wp-includes/Requests/src/Proxy/Http.php create mode 100644 wp-includes/Requests/src/Requests.php create mode 100644 wp-includes/Requests/src/Response.php create mode 100644 wp-includes/Requests/src/Response/Headers.php create mode 100644 wp-includes/Requests/src/Session.php create mode 100644 wp-includes/Requests/src/Ssl.php create mode 100644 wp-includes/Requests/src/Transport.php create mode 100644 wp-includes/Requests/src/Transport/Curl.php create mode 100644 wp-includes/Requests/src/Transport/Fsockopen.php create mode 100644 wp-includes/Requests/src/Utility/CaseInsensitiveDictionary.php create mode 100644 wp-includes/Requests/src/Utility/FilteredIterator.php create mode 100644 wp-includes/Requests/src/Utility/InputValidator.php (limited to 'wp-includes/Requests') diff --git a/wp-includes/Requests/library/Requests.php b/wp-includes/Requests/library/Requests.php new file mode 100644 index 0000000..9e42db5 --- /dev/null +++ b/wp-includes/Requests/library/Requests.php @@ -0,0 +1,12 @@ +user, $this->pass) = $args; + return; + } + + if ($args !== null) { + throw InvalidArgument::create(1, '$args', 'array|null', gettype($args)); + } + } + + /** + * Register the necessary callbacks + * + * @see \WpOrg\Requests\Auth\Basic::curl_before_send() + * @see \WpOrg\Requests\Auth\Basic::fsockopen_header() + * @param \WpOrg\Requests\Hooks $hooks Hook system + */ + public function register(Hooks $hooks) { + $hooks->register('curl.before_send', [$this, 'curl_before_send']); + $hooks->register('fsockopen.after_headers', [$this, 'fsockopen_header']); + } + + /** + * Set cURL parameters before the data is sent + * + * @param resource|\CurlHandle $handle cURL handle + */ + public function curl_before_send(&$handle) { + curl_setopt($handle, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + curl_setopt($handle, CURLOPT_USERPWD, $this->getAuthString()); + } + + /** + * Add extra headers to the request before sending + * + * @param string $out HTTP header string + */ + public function fsockopen_header(&$out) { + $out .= sprintf("Authorization: Basic %s\r\n", base64_encode($this->getAuthString())); + } + + /** + * Get the authentication string (user:pass) + * + * @return string + */ + public function getAuthString() { + return $this->user . ':' . $this->pass; + } +} diff --git a/wp-includes/Requests/src/Autoload.php b/wp-includes/Requests/src/Autoload.php new file mode 100644 index 0000000..669ddec --- /dev/null +++ b/wp-includes/Requests/src/Autoload.php @@ -0,0 +1,187 @@ + '\WpOrg\Requests\Auth', + 'requests_hooker' => '\WpOrg\Requests\HookManager', + 'requests_proxy' => '\WpOrg\Requests\Proxy', + 'requests_transport' => '\WpOrg\Requests\Transport', + + // Classes. + 'requests_cookie' => '\WpOrg\Requests\Cookie', + 'requests_exception' => '\WpOrg\Requests\Exception', + 'requests_hooks' => '\WpOrg\Requests\Hooks', + 'requests_idnaencoder' => '\WpOrg\Requests\IdnaEncoder', + 'requests_ipv6' => '\WpOrg\Requests\Ipv6', + 'requests_iri' => '\WpOrg\Requests\Iri', + 'requests_response' => '\WpOrg\Requests\Response', + 'requests_session' => '\WpOrg\Requests\Session', + 'requests_ssl' => '\WpOrg\Requests\Ssl', + 'requests_auth_basic' => '\WpOrg\Requests\Auth\Basic', + 'requests_cookie_jar' => '\WpOrg\Requests\Cookie\Jar', + 'requests_proxy_http' => '\WpOrg\Requests\Proxy\Http', + 'requests_response_headers' => '\WpOrg\Requests\Response\Headers', + 'requests_transport_curl' => '\WpOrg\Requests\Transport\Curl', + 'requests_transport_fsockopen' => '\WpOrg\Requests\Transport\Fsockopen', + 'requests_utility_caseinsensitivedictionary' => '\WpOrg\Requests\Utility\CaseInsensitiveDictionary', + 'requests_utility_filterediterator' => '\WpOrg\Requests\Utility\FilteredIterator', + 'requests_exception_http' => '\WpOrg\Requests\Exception\Http', + 'requests_exception_transport' => '\WpOrg\Requests\Exception\Transport', + 'requests_exception_transport_curl' => '\WpOrg\Requests\Exception\Transport\Curl', + 'requests_exception_http_304' => '\WpOrg\Requests\Exception\Http\Status304', + 'requests_exception_http_305' => '\WpOrg\Requests\Exception\Http\Status305', + 'requests_exception_http_306' => '\WpOrg\Requests\Exception\Http\Status306', + 'requests_exception_http_400' => '\WpOrg\Requests\Exception\Http\Status400', + 'requests_exception_http_401' => '\WpOrg\Requests\Exception\Http\Status401', + 'requests_exception_http_402' => '\WpOrg\Requests\Exception\Http\Status402', + 'requests_exception_http_403' => '\WpOrg\Requests\Exception\Http\Status403', + 'requests_exception_http_404' => '\WpOrg\Requests\Exception\Http\Status404', + 'requests_exception_http_405' => '\WpOrg\Requests\Exception\Http\Status405', + 'requests_exception_http_406' => '\WpOrg\Requests\Exception\Http\Status406', + 'requests_exception_http_407' => '\WpOrg\Requests\Exception\Http\Status407', + 'requests_exception_http_408' => '\WpOrg\Requests\Exception\Http\Status408', + 'requests_exception_http_409' => '\WpOrg\Requests\Exception\Http\Status409', + 'requests_exception_http_410' => '\WpOrg\Requests\Exception\Http\Status410', + 'requests_exception_http_411' => '\WpOrg\Requests\Exception\Http\Status411', + 'requests_exception_http_412' => '\WpOrg\Requests\Exception\Http\Status412', + 'requests_exception_http_413' => '\WpOrg\Requests\Exception\Http\Status413', + 'requests_exception_http_414' => '\WpOrg\Requests\Exception\Http\Status414', + 'requests_exception_http_415' => '\WpOrg\Requests\Exception\Http\Status415', + 'requests_exception_http_416' => '\WpOrg\Requests\Exception\Http\Status416', + 'requests_exception_http_417' => '\WpOrg\Requests\Exception\Http\Status417', + 'requests_exception_http_418' => '\WpOrg\Requests\Exception\Http\Status418', + 'requests_exception_http_428' => '\WpOrg\Requests\Exception\Http\Status428', + 'requests_exception_http_429' => '\WpOrg\Requests\Exception\Http\Status429', + 'requests_exception_http_431' => '\WpOrg\Requests\Exception\Http\Status431', + 'requests_exception_http_500' => '\WpOrg\Requests\Exception\Http\Status500', + 'requests_exception_http_501' => '\WpOrg\Requests\Exception\Http\Status501', + 'requests_exception_http_502' => '\WpOrg\Requests\Exception\Http\Status502', + 'requests_exception_http_503' => '\WpOrg\Requests\Exception\Http\Status503', + 'requests_exception_http_504' => '\WpOrg\Requests\Exception\Http\Status504', + 'requests_exception_http_505' => '\WpOrg\Requests\Exception\Http\Status505', + 'requests_exception_http_511' => '\WpOrg\Requests\Exception\Http\Status511', + 'requests_exception_http_unknown' => '\WpOrg\Requests\Exception\Http\StatusUnknown', + ]; + + /** + * Register the autoloader. + * + * Note: the autoloader is *prepended* in the autoload queue. + * This is done to ensure that the Requests 2.0 autoloader takes precedence + * over a potentially (dependency-registered) Requests 1.x autoloader. + * + * @internal This method contains a safeguard against the autoloader being + * registered multiple times. This safeguard uses a global constant to + * (hopefully/in most cases) still function correctly, even if the + * class would be renamed. + * + * @return void + */ + public static function register() { + if (defined('REQUESTS_AUTOLOAD_REGISTERED') === false) { + spl_autoload_register([self::class, 'load'], true); + define('REQUESTS_AUTOLOAD_REGISTERED', true); + } + } + + /** + * Autoloader. + * + * @param string $class_name Name of the class name to load. + * + * @return bool Whether a class was loaded or not. + */ + public static function load($class_name) { + // Check that the class starts with "Requests" (PSR-0) or "WpOrg\Requests" (PSR-4). + $psr_4_prefix_pos = strpos($class_name, 'WpOrg\\Requests\\'); + + if (stripos($class_name, 'Requests') !== 0 && $psr_4_prefix_pos !== 0) { + return false; + } + + $class_lower = strtolower($class_name); + + if ($class_lower === 'requests') { + // Reference to the original PSR-0 Requests class. + $file = dirname(__DIR__) . '/library/Requests.php'; + } elseif ($psr_4_prefix_pos === 0) { + // PSR-4 classname. + $file = __DIR__ . '/' . strtr(substr($class_name, 15), '\\', '/') . '.php'; + } + + if (isset($file) && file_exists($file)) { + include $file; + return true; + } + + /* + * Okay, so the class starts with "Requests", but we couldn't find the file. + * If this is one of the deprecated/renamed PSR-0 classes being requested, + * let's alias it to the new name and throw a deprecation notice. + */ + if (isset(self::$deprecated_classes[$class_lower])) { + /* + * Integrators who cannot yet upgrade to the PSR-4 class names can silence deprecations + * by defining a `REQUESTS_SILENCE_PSR0_DEPRECATIONS` constant and setting it to `true`. + * The constant needs to be defined before the first deprecated class is requested + * via this autoloader. + */ + if (!defined('REQUESTS_SILENCE_PSR0_DEPRECATIONS') || REQUESTS_SILENCE_PSR0_DEPRECATIONS !== true) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error( + 'The PSR-0 `Requests_...` class names in the Requests library are deprecated.' + . ' Switch to the PSR-4 `WpOrg\Requests\...` class names at your earliest convenience.', + E_USER_DEPRECATED + ); + + // Prevent the deprecation notice from being thrown twice. + if (!defined('REQUESTS_SILENCE_PSR0_DEPRECATIONS')) { + define('REQUESTS_SILENCE_PSR0_DEPRECATIONS', true); + } + } + + // Create an alias and let the autoloader recursively kick in to load the PSR-4 class. + return class_alias(self::$deprecated_classes[$class_lower], $class_name, true); + } + + return false; + } + } +} diff --git a/wp-includes/Requests/src/Capability.php b/wp-includes/Requests/src/Capability.php new file mode 100644 index 0000000..30572ba --- /dev/null +++ b/wp-includes/Requests/src/Capability.php @@ -0,0 +1,36 @@ +name = $name; + $this->value = $value; + $this->attributes = $attributes; + $default_flags = [ + 'creation' => time(), + 'last-access' => time(), + 'persistent' => false, + 'host-only' => true, + ]; + $this->flags = array_merge($default_flags, $flags); + + $this->reference_time = time(); + if ($reference_time !== null) { + $this->reference_time = $reference_time; + } + + $this->normalize(); + } + + /** + * Get the cookie value + * + * Attributes and other data can be accessed via methods. + */ + public function __toString() { + return $this->value; + } + + /** + * Check if a cookie is expired. + * + * Checks the age against $this->reference_time to determine if the cookie + * is expired. + * + * @return boolean True if expired, false if time is valid. + */ + public function is_expired() { + // RFC6265, s. 4.1.2.2: + // If a cookie has both the Max-Age and the Expires attribute, the Max- + // Age attribute has precedence and controls the expiration date of the + // cookie. + if (isset($this->attributes['max-age'])) { + $max_age = $this->attributes['max-age']; + return $max_age < $this->reference_time; + } + + if (isset($this->attributes['expires'])) { + $expires = $this->attributes['expires']; + return $expires < $this->reference_time; + } + + return false; + } + + /** + * Check if a cookie is valid for a given URI + * + * @param \WpOrg\Requests\Iri $uri URI to check + * @return boolean Whether the cookie is valid for the given URI + */ + public function uri_matches(Iri $uri) { + if (!$this->domain_matches($uri->host)) { + return false; + } + + if (!$this->path_matches($uri->path)) { + return false; + } + + return empty($this->attributes['secure']) || $uri->scheme === 'https'; + } + + /** + * Check if a cookie is valid for a given domain + * + * @param string $domain Domain to check + * @return boolean Whether the cookie is valid for the given domain + */ + public function domain_matches($domain) { + if (is_string($domain) === false) { + return false; + } + + if (!isset($this->attributes['domain'])) { + // Cookies created manually; cookies created by Requests will set + // the domain to the requested domain + return true; + } + + $cookie_domain = $this->attributes['domain']; + if ($cookie_domain === $domain) { + // The cookie domain and the passed domain 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($domain) <= strlen($cookie_domain)) { + // For obvious reasons, the cookie domain cannot be a suffix if the passed domain + // is shorter than the cookie domain + return false; + } + + if (substr($domain, -1 * strlen($cookie_domain)) !== $cookie_domain) { + // The cookie domain should be a suffix of the passed domain. + return false; + } + + $prefix = substr($domain, 0, strlen($domain) - strlen($cookie_domain)); + if (substr($prefix, -1) !== '.') { + // The last character of the passed domain that is not included in the + // domain string should be a %x2E (".") character. + return false; + } + + // The passed domain should be a host name (i.e., not an IP address). + return !preg_match('#^(.+\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $domain); + } + + /** + * 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 path_matches($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; + } + + if (is_scalar($request_path) === false) { + return false; + } + + $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; + + if (is_string($key)) { + $value = $this->normalize_attribute($key, $value); + } + + if ($value === null) { + unset($this->attributes[$key]); + continue; + } + + if ($value !== $orig_value) { + $this->attributes[$key] = $value; + } + } + + return true; + } + + /** + * Parse an individual cookie attribute + * + * Handles parsing individual attributes from the cookie values. + * + * @param string $name Attribute name + * @param string|int|bool $value Attribute value (string/integer value, or true if empty/flag) + * @return mixed Value if available, or null if the attribute value is invalid (and should be skipped) + */ + protected function normalize_attribute($name, $value) { + switch (strtolower($name)) { + case 'expires': + // Expiration parsing, as per RFC 6265 section 5.2.1 + if (is_int($value)) { + return $value; + } + + $expiry_time = strtotime($value); + if ($expiry_time === false) { + return null; + } + + return $expiry_time; + + case 'max-age': + // Expiration parsing, as per RFC 6265 section 5.2.2 + if (is_int($value)) { + return $value; + } + + // Check that we have a valid age + if (!preg_match('/^-?\d+$/', $value)) { + return null; + } + + $delta_seconds = (int) $value; + if ($delta_seconds <= 0) { + $expiry_time = 0; + } else { + $expiry_time = $this->reference_time + $delta_seconds; + } + + return $expiry_time; + + case 'domain': + // Domains are not required as per RFC 6265 section 5.2.3 + if (empty($value)) { + return null; + } + + // Domain normalization, as per RFC 6265 section 5.2.3 + if ($value[0] === '.') { + $value = substr($value, 1); + } + + return $value; + + default: + return $value; + } + } + + /** + * Format a cookie for a Cookie header + * + * This is used when sending cookies to a server. + * + * @return string Cookie formatted for Cookie header + */ + public function format_for_header() { + return sprintf('%s=%s', $this->name, $this->value); + } + + /** + * Format a cookie for a Set-Cookie header + * + * This is used when sending cookies to clients. This isn't really + * applicable to client-side usage, but might be handy for debugging. + * + * @return string Cookie formatted for Set-Cookie header + */ + public function format_for_set_cookie() { + $header_value = $this->format_for_header(); + if (!empty($this->attributes)) { + $parts = []; + foreach ($this->attributes as $key => $value) { + // Ignore non-associative attributes + if (is_numeric($key)) { + $parts[] = $value; + } else { + $parts[] = sprintf('%s=%s', $key, $value); + } + } + + $header_value .= '; ' . implode('; ', $parts); + } + + return $header_value; + } + + /** + * Parse a cookie string into a cookie object + * + * Based on Mozilla's parsing code in Firefox and related projects, which + * is an intentional deviation from RFC 2109 and RFC 2616. RFC 6265 + * specifies some of this handling, but not in a thorough manner. + * + * @param string $cookie_header Cookie header value (from a Set-Cookie header) + * @param string $name + * @param int|null $reference_time + * @return \WpOrg\Requests\Cookie Parsed cookie object + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $cookie_header argument is not a string. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string. + */ + public static function parse($cookie_header, $name = '', $reference_time = null) { + if (is_string($cookie_header) === false) { + throw InvalidArgument::create(1, '$cookie_header', 'string', gettype($cookie_header)); + } + + if (is_string($name) === false) { + throw InvalidArgument::create(2, '$name', 'string', gettype($name)); + } + + $parts = explode(';', $cookie_header); + $kvparts = array_shift($parts); + + if (!empty($name)) { + $value = $cookie_header; + } elseif (strpos($kvparts, '=') === false) { + // Some sites might only have a value without the equals separator. + // Deviate from RFC 6265 and pretend it was actually a blank name + // (`=foo`) + // + // https://bugzilla.mozilla.org/show_bug.cgi?id=169091 + $name = ''; + $value = $kvparts; + } else { + list($name, $value) = explode('=', $kvparts, 2); + } + + $name = trim($name); + $value = trim($value); + + // Attribute keys are handled case-insensitively + $attributes = new CaseInsensitiveDictionary(); + + if (!empty($parts)) { + foreach ($parts as $part) { + if (strpos($part, '=') === false) { + $part_key = $part; + $part_value = true; + } else { + list($part_key, $part_value) = explode('=', $part, 2); + $part_value = trim($part_value); + } + + $part_key = trim($part_key); + $attributes[$part_key] = $part_value; + } + } + + return new static($name, $value, $attributes, [], $reference_time); + } + + /** + * Parse all Set-Cookie headers from request headers + * + * @param \WpOrg\Requests\Response\Headers $headers Headers to parse from + * @param \WpOrg\Requests\Iri|null $origin URI for comparing cookie origins + * @param int|null $time Reference time for expiration calculation + * @return array + */ + public static function parse_from_headers(Headers $headers, Iri $origin = null, $time = null) { + $cookie_headers = $headers->getValues('Set-Cookie'); + if (empty($cookie_headers)) { + return []; + } + + $cookies = []; + foreach ($cookie_headers as $header) { + $parsed = self::parse($header, '', $time); + + // Default domain/path attributes + if (empty($parsed->attributes['domain']) && !empty($origin)) { + $parsed->attributes['domain'] = $origin->host; + $parsed->flags['host-only'] = true; + } else { + $parsed->flags['host-only'] = false; + } + + $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 (!empty($origin) && !$parsed->domain_matches($origin->host)) { + continue; + } + + $cookies[$parsed->name] = $parsed; + } + + return $cookies; + } +} diff --git a/wp-includes/Requests/src/Cookie/Jar.php b/wp-includes/Requests/src/Cookie/Jar.php new file mode 100644 index 0000000..7633786 --- /dev/null +++ b/wp-includes/Requests/src/Cookie/Jar.php @@ -0,0 +1,187 @@ +cookies = $cookies; + } + + /** + * Normalise cookie data into a \WpOrg\Requests\Cookie + * + * @param string|\WpOrg\Requests\Cookie $cookie Cookie header value, possibly pre-parsed (object). + * @param string $key Optional. The name for this cookie. + * @return \WpOrg\Requests\Cookie + */ + public function normalize_cookie($cookie, $key = '') { + if ($cookie instanceof Cookie) { + return $cookie; + } + + return Cookie::parse($cookie, $key); + } + + /** + * Check if the given item exists + * + * @param string $offset Item key + * @return boolean Does the item exist? + */ + #[ReturnTypeWillChange] + public function offsetExists($offset) { + return isset($this->cookies[$offset]); + } + + /** + * Get the value for the item + * + * @param string $offset Item key + * @return string|null Item value (null if offsetExists is false) + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) { + if (!isset($this->cookies[$offset])) { + return null; + } + + return $this->cookies[$offset]; + } + + /** + * Set the given item + * + * @param string $offset Item name + * @param string $value Item value + * + * @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`) + */ + #[ReturnTypeWillChange] + public function offsetSet($offset, $value) { + if ($offset === null) { + throw new Exception('Object is a dictionary, not a list', 'invalidset'); + } + + $this->cookies[$offset] = $value; + } + + /** + * Unset the given header + * + * @param string $offset The key for the item to unset. + */ + #[ReturnTypeWillChange] + public function offsetUnset($offset) { + unset($this->cookies[$offset]); + } + + /** + * Get an iterator for the data + * + * @return \ArrayIterator + */ + #[ReturnTypeWillChange] + public function getIterator() { + return new ArrayIterator($this->cookies); + } + + /** + * Register the cookie handler with the request's hooking system + * + * @param \WpOrg\Requests\HookManager $hooks Hooking system + */ + public function register(HookManager $hooks) { + $hooks->register('requests.before_request', [$this, 'before_request']); + $hooks->register('requests.before_redirect_check', [$this, 'before_redirect_check']); + } + + /** + * Add Cookie header to a request if we have any + * + * As per RFC 6265, cookies are separated by '; ' + * + * @param string $url + * @param array $headers + * @param array $data + * @param string $type + * @param array $options + */ + public function before_request($url, &$headers, &$data, &$type, &$options) { + if (!$url instanceof Iri) { + $url = new Iri($url); + } + + if (!empty($this->cookies)) { + $cookies = []; + foreach ($this->cookies as $key => $cookie) { + $cookie = $this->normalize_cookie($cookie, $key); + + // Skip expired cookies + if ($cookie->is_expired()) { + continue; + } + + if ($cookie->domain_matches($url->host)) { + $cookies[] = $cookie->format_for_header(); + } + } + + $headers['Cookie'] = implode('; ', $cookies); + } + } + + /** + * Parse all cookies from a response and attach them to the response + * + * @param \WpOrg\Requests\Response $response Response as received. + */ + public function before_redirect_check(Response $response) { + $url = $response->url; + if (!$url instanceof Iri) { + $url = new Iri($url); + } + + $cookies = Cookie::parse_from_headers($response->headers, $url); + $this->cookies = array_merge($this->cookies, $cookies); + $response->cookies = $this; + } +} diff --git a/wp-includes/Requests/src/Exception.php b/wp-includes/Requests/src/Exception.php new file mode 100644 index 0000000..b67d1b1 --- /dev/null +++ b/wp-includes/Requests/src/Exception.php @@ -0,0 +1,66 @@ +type = $type; + $this->data = $data; + } + + /** + * Like {@see \Exception::getCode()}, but a string code. + * + * @codeCoverageIgnore + * @return string + */ + public function getType() { + return $this->type; + } + + /** + * Gives any relevant data + * + * @codeCoverageIgnore + * @return mixed + */ + public function getData() { + return $this->data; + } +} diff --git a/wp-includes/Requests/src/Exception/ArgumentCount.php b/wp-includes/Requests/src/Exception/ArgumentCount.php new file mode 100644 index 0000000..b5773dd --- /dev/null +++ b/wp-includes/Requests/src/Exception/ArgumentCount.php @@ -0,0 +1,47 @@ +reason = $reason; + } + + $message = sprintf('%d %s', $this->code, $this->reason); + parent::__construct($message, 'httpresponse', $data, $this->code); + } + + /** + * Get the status message. + * + * @return string + */ + public function getReason() { + return $this->reason; + } + + /** + * Get the correct exception class for a given error code + * + * @param int|bool $code HTTP status code, or false if unavailable + * @return string Exception class name to use + */ + public static function get_class($code) { + if (!$code) { + return StatusUnknown::class; + } + + $class = sprintf('\WpOrg\Requests\Exception\Http\Status%d', $code); + if (class_exists($class)) { + return $class; + } + + return StatusUnknown::class; + } +} diff --git a/wp-includes/Requests/src/Exception/Http/Status304.php b/wp-includes/Requests/src/Exception/Http/Status304.php new file mode 100644 index 0000000..d510ae7 --- /dev/null +++ b/wp-includes/Requests/src/Exception/Http/Status304.php @@ -0,0 +1,31 @@ +code = (int) $data->status_code; + } + + parent::__construct($reason, $data); + } +} diff --git a/wp-includes/Requests/src/Exception/InvalidArgument.php b/wp-includes/Requests/src/Exception/InvalidArgument.php new file mode 100644 index 0000000..0ab7332 --- /dev/null +++ b/wp-includes/Requests/src/Exception/InvalidArgument.php @@ -0,0 +1,41 @@ +type = $type; + } + + if ($code !== null) { + $this->code = (int) $code; + } + + if ($message !== null) { + $this->reason = $message; + } + + $message = sprintf('%d %s', $this->code, $this->reason); + parent::__construct($message, $this->type, $data, $this->code); + } + + /** + * Get the error message. + * + * @return string + */ + public function getReason() { + return $this->reason; + } + +} diff --git a/wp-includes/Requests/src/HookManager.php b/wp-includes/Requests/src/HookManager.php new file mode 100644 index 0000000..f292017 --- /dev/null +++ b/wp-includes/Requests/src/HookManager.php @@ -0,0 +1,33 @@ +0 is executed later + */ + public function register($hook, $callback, $priority = 0); + + /** + * Dispatch a message + * + * @param string $hook Hook name + * @param array $parameters Parameters to pass to callbacks + * @return boolean Successfulness + */ + public function dispatch($hook, $parameters = []); +} diff --git a/wp-includes/Requests/src/Hooks.php b/wp-includes/Requests/src/Hooks.php new file mode 100644 index 0000000..d8023ed --- /dev/null +++ b/wp-includes/Requests/src/Hooks.php @@ -0,0 +1,103 @@ +0 is executed later + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $hook argument is not a string. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $callback argument is not callable. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $priority argument is not an integer. + */ + public function register($hook, $callback, $priority = 0) { + if (is_string($hook) === false) { + throw InvalidArgument::create(1, '$hook', 'string', gettype($hook)); + } + + if (is_callable($callback) === false) { + throw InvalidArgument::create(2, '$callback', 'callable', gettype($callback)); + } + + if (InputValidator::is_numeric_array_key($priority) === false) { + throw InvalidArgument::create(3, '$priority', 'integer', gettype($priority)); + } + + if (!isset($this->hooks[$hook])) { + $this->hooks[$hook] = [ + $priority => [], + ]; + } elseif (!isset($this->hooks[$hook][$priority])) { + $this->hooks[$hook][$priority] = []; + } + + $this->hooks[$hook][$priority][] = $callback; + } + + /** + * Dispatch a message + * + * @param string $hook Hook name + * @param array $parameters Parameters to pass to callbacks + * @return boolean Successfulness + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $hook argument is not a string. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $parameters argument is not an array. + */ + public function dispatch($hook, $parameters = []) { + if (is_string($hook) === false) { + throw InvalidArgument::create(1, '$hook', 'string', gettype($hook)); + } + + // Check strictly against array, as Array* objects don't work in combination with `call_user_func_array()`. + if (is_array($parameters) === false) { + throw InvalidArgument::create(2, '$parameters', 'array', gettype($parameters)); + } + + if (empty($this->hooks[$hook])) { + return false; + } + + if (!empty($parameters)) { + // Strip potential keys from the array to prevent them being interpreted as parameter names in PHP 8.0. + $parameters = array_values($parameters); + } + + ksort($this->hooks[$hook]); + + foreach ($this->hooks[$hook] as $priority => $hooked) { + foreach ($hooked as $callback) { + $callback(...$parameters); + } + } + + return true; + } + + public function __wakeup() { + throw new \LogicException( __CLASS__ . ' should never be unserialized' ); + } +} diff --git a/wp-includes/Requests/src/IdnaEncoder.php b/wp-includes/Requests/src/IdnaEncoder.php new file mode 100644 index 0000000..4257a1a --- /dev/null +++ b/wp-includes/Requests/src/IdnaEncoder.php @@ -0,0 +1,412 @@ + 0) { + if ($position + $length > $strlen) { + throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character); + } + + for ($position++; $remaining > 0; $position++) { + $value = ord($input[$position]); + + // If it is invalid, count the sequence as invalid and reprocess the current byte: + if (($value & 0xC0) !== 0x80) { + throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character); + } + + --$remaining; + $character |= ($value & 0x3F) << ($remaining * 6); + } + + $position--; + } + + if (// Non-shortest form sequences are invalid + $length > 1 && $character <= 0x7F + || $length > 2 && $character <= 0x7FF + || $length > 3 && $character <= 0xFFFF + // Outside of range of ucschar codepoints + // Noncharacters + || ($character & 0xFFFE) === 0xFFFE + || $character >= 0xFDD0 && $character <= 0xFDEF + || ( + // Everything else not in ucschar + $character > 0xD7FF && $character < 0xF900 + || $character < 0x20 + || $character > 0x7E && $character < 0xA0 + || $character > 0xEFFFD + ) + ) { + throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character); + } + + $codepoints[] = $character; + } + + return $codepoints; + } + + /** + * RFC3492-compliant encoder + * + * @internal Pseudo-code from Section 6.3 is commented with "#" next to relevant code + * + * @param string $input UTF-8 encoded string to encode + * @return string Punycode-encoded string + * + * @throws \WpOrg\Requests\Exception On character outside of the domain (never happens with Punycode) (`idna.character_outside_domain`) + */ + public static function punycode_encode($input) { + $output = ''; + // let n = initial_n + $n = self::BOOTSTRAP_INITIAL_N; + // let delta = 0 + $delta = 0; + // let bias = initial_bias + $bias = self::BOOTSTRAP_INITIAL_BIAS; + // let h = b = the number of basic code points in the input + $h = 0; + $b = 0; // see loop + // copy them to the output in order + $codepoints = self::utf8_to_codepoints($input); + $extended = []; + + foreach ($codepoints as $char) { + if ($char < 128) { + // Character is valid ASCII + // TODO: this should also check if it's valid for a URL + $output .= chr($char); + $h++; + + // Check if the character is non-ASCII, but below initial n + // This never occurs for Punycode, so ignore in coverage + // @codeCoverageIgnoreStart + } elseif ($char < $n) { + throw new Exception('Invalid character', 'idna.character_outside_domain', $char); + // @codeCoverageIgnoreEnd + } else { + $extended[$char] = true; + } + } + + $extended = array_keys($extended); + sort($extended); + $b = $h; + // [copy them] followed by a delimiter if b > 0 + if (strlen($output) > 0) { + $output .= '-'; + } + + // {if the input contains a non-basic code point < n then fail} + // while h < length(input) do begin + $codepointcount = count($codepoints); + while ($h < $codepointcount) { + // let m = the minimum code point >= n in the input + $m = array_shift($extended); + //printf('next code point to insert is %s' . PHP_EOL, dechex($m)); + // let delta = delta + (m - n) * (h + 1), fail on overflow + $delta += ($m - $n) * ($h + 1); + // let n = m + $n = $m; + // for each code point c in the input (in order) do begin + for ($num = 0; $num < $codepointcount; $num++) { + $c = $codepoints[$num]; + // if c < n then increment delta, fail on overflow + if ($c < $n) { + $delta++; + } elseif ($c === $n) { // if c == n then begin + // let q = delta + $q = $delta; + // for k = base to infinity in steps of base do begin + for ($k = self::BOOTSTRAP_BASE; ; $k += self::BOOTSTRAP_BASE) { + // let t = tmin if k <= bias {+ tmin}, or + // tmax if k >= bias + tmax, or k - bias otherwise + if ($k <= ($bias + self::BOOTSTRAP_TMIN)) { + $t = self::BOOTSTRAP_TMIN; + } elseif ($k >= ($bias + self::BOOTSTRAP_TMAX)) { + $t = self::BOOTSTRAP_TMAX; + } else { + $t = $k - $bias; + } + + // if q < t then break + if ($q < $t) { + break; + } + + // output the code point for digit t + ((q - t) mod (base - t)) + $digit = (int) ($t + (($q - $t) % (self::BOOTSTRAP_BASE - $t))); + $output .= self::digit_to_char($digit); + // let q = (q - t) div (base - t) + $q = (int) floor(($q - $t) / (self::BOOTSTRAP_BASE - $t)); + } // end + // output the code point for digit q + $output .= self::digit_to_char($q); + // let bias = adapt(delta, h + 1, test h equals b?) + $bias = self::adapt($delta, $h + 1, $h === $b); + // let delta = 0 + $delta = 0; + // increment h + $h++; + } // end + } // end + // increment delta and n + $delta++; + $n++; + } // end + + return $output; + } + + /** + * Convert a digit to its respective character + * + * @link https://tools.ietf.org/html/rfc3492#section-5 + * + * @param int $digit Digit in the range 0-35 + * @return string Single character corresponding to digit + * + * @throws \WpOrg\Requests\Exception On invalid digit (`idna.invalid_digit`) + */ + protected static function digit_to_char($digit) { + // @codeCoverageIgnoreStart + // As far as I know, this never happens, but still good to be sure. + if ($digit < 0 || $digit > 35) { + throw new Exception(sprintf('Invalid digit %d', $digit), 'idna.invalid_digit', $digit); + } + + // @codeCoverageIgnoreEnd + $digits = 'abcdefghijklmnopqrstuvwxyz0123456789'; + return substr($digits, $digit, 1); + } + + /** + * Adapt the bias + * + * @link https://tools.ietf.org/html/rfc3492#section-6.1 + * @param int $delta + * @param int $numpoints + * @param bool $firsttime + * @return int|float New bias + * + * function adapt(delta,numpoints,firsttime): + */ + protected static function adapt($delta, $numpoints, $firsttime) { + // if firsttime then let delta = delta div damp + if ($firsttime) { + $delta = floor($delta / self::BOOTSTRAP_DAMP); + } else { + // else let delta = delta div 2 + $delta = floor($delta / 2); + } + + // let delta = delta + (delta div numpoints) + $delta += floor($delta / $numpoints); + // let k = 0 + $k = 0; + // while delta > ((base - tmin) * tmax) div 2 do begin + $max = floor(((self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN) * self::BOOTSTRAP_TMAX) / 2); + while ($delta > $max) { + // let delta = delta div (base - tmin) + $delta = floor($delta / (self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN)); + // let k = k + base + $k += self::BOOTSTRAP_BASE; + } // end + // return k + (((base - tmin + 1) * delta) div (delta + skew)) + return $k + floor(((self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN + 1) * $delta) / ($delta + self::BOOTSTRAP_SKEW)); + } +} diff --git a/wp-includes/Requests/src/Ipv6.php b/wp-includes/Requests/src/Ipv6.php new file mode 100644 index 0000000..a90ab8a --- /dev/null +++ b/wp-includes/Requests/src/Ipv6.php @@ -0,0 +1,203 @@ + FF01:0:0:0:0:0:0:101 + * ::1 -> 0:0:0:0:0:0:0:1 + * + * @author Alexander Merz + * @author elfrink at introweb dot nl + * @author Josh Peck + * @copyright 2003-2005 The PHP Group + * @license https://opensource.org/licenses/bsd-license.php + * + * @param string|Stringable $ip An IPv6 address + * @return string The uncompressed IPv6 address + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or a stringable object. + */ + public static function uncompress($ip) { + if (InputValidator::is_string_or_stringable($ip) === false) { + throw InvalidArgument::create(1, '$ip', 'string|Stringable', gettype($ip)); + } + + $ip = (string) $ip; + + if (substr_count($ip, '::') !== 1) { + return $ip; + } + + list($ip1, $ip2) = explode('::', $ip); + $c1 = ($ip1 === '') ? -1 : substr_count($ip1, ':'); + $c2 = ($ip2 === '') ? -1 : substr_count($ip2, ':'); + + if (strpos($ip2, '.') !== false) { + $c2++; + } + + if ($c1 === -1 && $c2 === -1) { + // :: + $ip = '0:0:0:0:0:0:0:0'; + } elseif ($c1 === -1) { + // ::xxx + $fill = str_repeat('0:', 7 - $c2); + $ip = str_replace('::', $fill, $ip); + } elseif ($c2 === -1) { + // xxx:: + $fill = str_repeat(':0', 7 - $c1); + $ip = str_replace('::', $fill, $ip); + } else { + // xxx::xxx + $fill = ':' . str_repeat('0:', 6 - $c2 - $c1); + $ip = str_replace('::', $fill, $ip); + } + + return $ip; + } + + /** + * Compresses an IPv6 address + * + * RFC 4291 allows you to compress consecutive zero pieces in an address to + * '::'. This method expects a valid IPv6 address and compresses consecutive + * zero pieces to '::'. + * + * Example: FF01:0:0:0:0:0:0:101 -> FF01::101 + * 0:0:0:0:0:0:0:1 -> ::1 + * + * @see \WpOrg\Requests\Ipv6::uncompress() + * + * @param string $ip An IPv6 address + * @return string The compressed IPv6 address + */ + public static function compress($ip) { + // Prepare the IP to be compressed. + // Note: Input validation is handled in the `uncompress()` method, which is the first call made in this method. + $ip = self::uncompress($ip); + $ip_parts = self::split_v6_v4($ip); + + // Replace all leading zeros + $ip_parts[0] = preg_replace('/(^|:)0+([0-9])/', '\1\2', $ip_parts[0]); + + // Find bunches of zeros + if (preg_match_all('/(?:^|:)(?:0(?::|$))+/', $ip_parts[0], $matches, PREG_OFFSET_CAPTURE)) { + $max = 0; + $pos = null; + foreach ($matches[0] as $match) { + if (strlen($match[0]) > $max) { + $max = strlen($match[0]); + $pos = $match[1]; + } + } + + $ip_parts[0] = substr_replace($ip_parts[0], '::', $pos, $max); + } + + if ($ip_parts[1] !== '') { + return implode(':', $ip_parts); + } else { + return $ip_parts[0]; + } + } + + /** + * Splits an IPv6 address into the IPv6 and IPv4 representation parts + * + * RFC 4291 allows you to represent the last two parts of an IPv6 address + * using the standard IPv4 representation + * + * Example: 0:0:0:0:0:0:13.1.68.3 + * 0:0:0:0:0:FFFF:129.144.52.38 + * + * @param string $ip An IPv6 address + * @return string[] [0] contains the IPv6 represented part, and [1] the IPv4 represented part + */ + private static function split_v6_v4($ip) { + if (strpos($ip, '.') !== false) { + $pos = strrpos($ip, ':'); + $ipv6_part = substr($ip, 0, $pos); + $ipv4_part = substr($ip, $pos + 1); + return [$ipv6_part, $ipv4_part]; + } else { + return [$ip, '']; + } + } + + /** + * Checks an IPv6 address + * + * Checks if the given IP is a valid IPv6 address + * + * @param string $ip An IPv6 address + * @return bool true if $ip is a valid IPv6 address + */ + public static function check_ipv6($ip) { + // Note: Input validation is handled in the `uncompress()` method, which is the first call made in this method. + $ip = self::uncompress($ip); + list($ipv6, $ipv4) = self::split_v6_v4($ip); + $ipv6 = explode(':', $ipv6); + $ipv4 = explode('.', $ipv4); + if (count($ipv6) === 8 && count($ipv4) === 1 || count($ipv6) === 6 && count($ipv4) === 4) { + foreach ($ipv6 as $ipv6_part) { + // The section can't be empty + if ($ipv6_part === '') { + return false; + } + + // Nor can it be over four characters + if (strlen($ipv6_part) > 4) { + return false; + } + + // Remove leading zeros (this is safe because of the above) + $ipv6_part = ltrim($ipv6_part, '0'); + if ($ipv6_part === '') { + $ipv6_part = '0'; + } + + // Check the value is valid + $value = hexdec($ipv6_part); + if (dechex($value) !== strtolower($ipv6_part) || $value < 0 || $value > 0xFFFF) { + return false; + } + } + + if (count($ipv4) === 4) { + foreach ($ipv4 as $ipv4_part) { + $value = (int) $ipv4_part; + if ((string) $value !== $ipv4_part || $value < 0 || $value > 0xFF) { + return false; + } + } + } + + return true; + } else { + return false; + } + } +} diff --git a/wp-includes/Requests/src/Iri.php b/wp-includes/Requests/src/Iri.php new file mode 100644 index 0000000..41ea7a8 --- /dev/null +++ b/wp-includes/Requests/src/Iri.php @@ -0,0 +1,1116 @@ + array( + 'port' => Port::ACAP, + ), + 'dict' => array( + 'port' => Port::DICT, + ), + 'file' => array( + 'ihost' => 'localhost', + ), + 'http' => array( + 'port' => Port::HTTP, + ), + 'https' => array( + 'port' => Port::HTTPS, + ), + ); + + /** + * Return the entire IRI when you try and read the object as a string + * + * @return string + */ + public function __toString() { + return $this->get_iri(); + } + + /** + * Overload __set() to provide access via properties + * + * @param string $name Property name + * @param mixed $value Property value + */ + public function __set($name, $value) { + if (method_exists($this, 'set_' . $name)) { + call_user_func(array($this, 'set_' . $name), $value); + } + elseif ( + $name === 'iauthority' + || $name === 'iuserinfo' + || $name === 'ihost' + || $name === 'ipath' + || $name === 'iquery' + || $name === 'ifragment' + ) { + call_user_func(array($this, 'set_' . substr($name, 1)), $value); + } + } + + /** + * Overload __get() to provide access via properties + * + * @param string $name Property name + * @return mixed + */ + public function __get($name) { + // isset() returns false for null, we don't want to do that + // Also why we use array_key_exists below instead of isset() + $props = get_object_vars($this); + + if ( + $name === 'iri' || + $name === 'uri' || + $name === 'iauthority' || + $name === 'authority' + ) { + $method = 'get_' . $name; + $return = $this->$method(); + } + elseif (array_key_exists($name, $props)) { + $return = $this->$name; + } + // host -> ihost + elseif (($prop = 'i' . $name) && array_key_exists($prop, $props)) { + $name = $prop; + $return = $this->$prop; + } + // ischeme -> scheme + elseif (($prop = substr($name, 1)) && array_key_exists($prop, $props)) { + $name = $prop; + $return = $this->$prop; + } + else { + trigger_error('Undefined property: ' . get_class($this) . '::' . $name, E_USER_NOTICE); + $return = null; + } + + if ($return === null && isset($this->normalization[$this->scheme][$name])) { + return $this->normalization[$this->scheme][$name]; + } + else { + return $return; + } + } + + /** + * Overload __isset() to provide access via properties + * + * @param string $name Property name + * @return bool + */ + public function __isset($name) { + return (method_exists($this, 'get_' . $name) || isset($this->$name)); + } + + /** + * Overload __unset() to provide access via properties + * + * @param string $name Property name + */ + public function __unset($name) { + if (method_exists($this, 'set_' . $name)) { + call_user_func(array($this, 'set_' . $name), ''); + } + } + + /** + * Create a new IRI object, from a specified string + * + * @param string|Stringable|null $iri + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $iri argument is not a string, Stringable or null. + */ + public function __construct($iri = null) { + if ($iri !== null && InputValidator::is_string_or_stringable($iri) === false) { + throw InvalidArgument::create(1, '$iri', 'string|Stringable|null', gettype($iri)); + } + + $this->set_iri($iri); + } + + /** + * Create a new IRI object by resolving a relative IRI + * + * Returns false if $base is not absolute, otherwise an IRI. + * + * @param \WpOrg\Requests\Iri|string $base (Absolute) Base IRI + * @param \WpOrg\Requests\Iri|string $relative Relative IRI + * @return \WpOrg\Requests\Iri|false + */ + public static function absolutize($base, $relative) { + if (!($relative instanceof self)) { + $relative = new self($relative); + } + if (!$relative->is_valid()) { + return false; + } + elseif ($relative->scheme !== null) { + return clone $relative; + } + + if (!($base instanceof self)) { + $base = new self($base); + } + if ($base->scheme === null || !$base->is_valid()) { + return false; + } + + if ($relative->get_iri() !== '') { + if ($relative->iuserinfo !== null || $relative->ihost !== null || $relative->port !== null) { + $target = clone $relative; + $target->scheme = $base->scheme; + } + else { + $target = new self; + $target->scheme = $base->scheme; + $target->iuserinfo = $base->iuserinfo; + $target->ihost = $base->ihost; + $target->port = $base->port; + if ($relative->ipath !== '') { + if ($relative->ipath[0] === '/') { + $target->ipath = $relative->ipath; + } + elseif (($base->iuserinfo !== null || $base->ihost !== null || $base->port !== null) && $base->ipath === '') { + $target->ipath = '/' . $relative->ipath; + } + elseif (($last_segment = strrpos($base->ipath, '/')) !== false) { + $target->ipath = substr($base->ipath, 0, $last_segment + 1) . $relative->ipath; + } + else { + $target->ipath = $relative->ipath; + } + $target->ipath = $target->remove_dot_segments($target->ipath); + $target->iquery = $relative->iquery; + } + else { + $target->ipath = $base->ipath; + if ($relative->iquery !== null) { + $target->iquery = $relative->iquery; + } + elseif ($base->iquery !== null) { + $target->iquery = $base->iquery; + } + } + $target->ifragment = $relative->ifragment; + } + } + else { + $target = clone $base; + $target->ifragment = null; + } + $target->scheme_normalization(); + return $target; + } + + /** + * Parse an IRI into scheme/authority/path/query/fragment segments + * + * @param string $iri + * @return array + */ + protected function parse_iri($iri) { + $iri = trim($iri, "\x20\x09\x0A\x0C\x0D"); + $has_match = preg_match('/^((?P[^:\/?#]+):)?(\/\/(?P[^\/?#]*))?(?P[^?#]*)(\?(?P[^#]*))?(#(?P.*))?$/', $iri, $match); + if (!$has_match) { + throw new Exception('Cannot parse supplied IRI', 'iri.cannot_parse', $iri); + } + + if ($match[1] === '') { + $match['scheme'] = null; + } + if (!isset($match[3]) || $match[3] === '') { + $match['authority'] = null; + } + if (!isset($match[5])) { + $match['path'] = ''; + } + if (!isset($match[6]) || $match[6] === '') { + $match['query'] = null; + } + if (!isset($match[8]) || $match[8] === '') { + $match['fragment'] = null; + } + return $match; + } + + /** + * Remove dot segments from a path + * + * @param string $input + * @return string + */ + protected function remove_dot_segments($input) { + $output = ''; + while (strpos($input, './') !== false || strpos($input, '/.') !== false || $input === '.' || $input === '..') { + // A: If the input buffer begins with a prefix of "../" or "./", + // then remove that prefix from the input buffer; otherwise, + if (strpos($input, '../') === 0) { + $input = substr($input, 3); + } + elseif (strpos($input, './') === 0) { + $input = substr($input, 2); + } + // B: if the input buffer begins with a prefix of "/./" or "/.", + // where "." is a complete path segment, then replace that prefix + // with "/" in the input buffer; otherwise, + elseif (strpos($input, '/./') === 0) { + $input = substr($input, 2); + } + elseif ($input === '/.') { + $input = '/'; + } + // C: if the input buffer begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that prefix + // with "/" in the input buffer and remove the last segment and its + // preceding "/" (if any) from the output buffer; otherwise, + elseif (strpos($input, '/../') === 0) { + $input = substr($input, 3); + $output = substr_replace($output, '', (strrpos($output, '/') ?: 0)); + } + elseif ($input === '/..') { + $input = '/'; + $output = substr_replace($output, '', (strrpos($output, '/') ?: 0)); + } + // D: if the input buffer consists only of "." or "..", then remove + // that from the input buffer; otherwise, + elseif ($input === '.' || $input === '..') { + $input = ''; + } + // E: move the first path segment in the input buffer to the end of + // the output buffer, including the initial "/" character (if any) + // and any subsequent characters up to, but not including, the next + // "/" character or the end of the input buffer + elseif (($pos = strpos($input, '/', 1)) !== false) { + $output .= substr($input, 0, $pos); + $input = substr_replace($input, '', 0, $pos); + } + else { + $output .= $input; + $input = ''; + } + } + return $output . $input; + } + + /** + * Replace invalid character with percent encoding + * + * @param string $text Input string + * @param string $extra_chars Valid characters not in iunreserved or + * iprivate (this is ASCII-only) + * @param bool $iprivate Allow iprivate + * @return string + */ + protected function replace_invalid_with_pct_encoding($text, $extra_chars, $iprivate = false) { + // Normalize as many pct-encoded sections as possible + $text = preg_replace_callback('/(?:%[A-Fa-f0-9]{2})+/', array($this, 'remove_iunreserved_percent_encoded'), $text); + + // Replace invalid percent characters + $text = preg_replace('/%(?![A-Fa-f0-9]{2})/', '%25', $text); + + // Add unreserved and % to $extra_chars (the latter is safe because all + // pct-encoded sections are now valid). + $extra_chars .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~%'; + + // Now replace any bytes that aren't allowed with their pct-encoded versions + $position = 0; + $strlen = strlen($text); + while (($position += strspn($text, $extra_chars, $position)) < $strlen) { + $value = ord($text[$position]); + + // Start position + $start = $position; + + // By default we are valid + $valid = true; + + // No one byte sequences are valid due to the while. + // Two byte sequence: + if (($value & 0xE0) === 0xC0) { + $character = ($value & 0x1F) << 6; + $length = 2; + $remaining = 1; + } + // Three byte sequence: + elseif (($value & 0xF0) === 0xE0) { + $character = ($value & 0x0F) << 12; + $length = 3; + $remaining = 2; + } + // Four byte sequence: + elseif (($value & 0xF8) === 0xF0) { + $character = ($value & 0x07) << 18; + $length = 4; + $remaining = 3; + } + // Invalid byte: + else { + $valid = false; + $length = 1; + $remaining = 0; + } + + if ($remaining) { + if ($position + $length <= $strlen) { + for ($position++; $remaining; $position++) { + $value = ord($text[$position]); + + // Check that the byte is valid, then add it to the character: + if (($value & 0xC0) === 0x80) { + $character |= ($value & 0x3F) << (--$remaining * 6); + } + // If it is invalid, count the sequence as invalid and reprocess the current byte: + else { + $valid = false; + $position--; + break; + } + } + } + else { + $position = $strlen - 1; + $valid = false; + } + } + + // Percent encode anything invalid or not in ucschar + if ( + // Invalid sequences + !$valid + // Non-shortest form sequences are invalid + || $length > 1 && $character <= 0x7F + || $length > 2 && $character <= 0x7FF + || $length > 3 && $character <= 0xFFFF + // Outside of range of ucschar codepoints + // Noncharacters + || ($character & 0xFFFE) === 0xFFFE + || $character >= 0xFDD0 && $character <= 0xFDEF + || ( + // Everything else not in ucschar + $character > 0xD7FF && $character < 0xF900 + || $character < 0xA0 + || $character > 0xEFFFD + ) + && ( + // Everything not in iprivate, if it applies + !$iprivate + || $character < 0xE000 + || $character > 0x10FFFD + ) + ) { + // If we were a character, pretend we weren't, but rather an error. + if ($valid) { + $position--; + } + + for ($j = $start; $j <= $position; $j++) { + $text = substr_replace($text, sprintf('%%%02X', ord($text[$j])), $j, 1); + $j += 2; + $position += 2; + $strlen += 2; + } + } + } + + return $text; + } + + /** + * Callback function for preg_replace_callback. + * + * Removes sequences of percent encoded bytes that represent UTF-8 + * encoded characters in iunreserved + * + * @param array $regex_match PCRE match + * @return string Replacement + */ + protected function remove_iunreserved_percent_encoded($regex_match) { + // As we just have valid percent encoded sequences we can just explode + // and ignore the first member of the returned array (an empty string). + $bytes = explode('%', $regex_match[0]); + + // Initialize the new string (this is what will be returned) and that + // there are no bytes remaining in the current sequence (unsurprising + // at the first byte!). + $string = ''; + $remaining = 0; + + // Loop over each and every byte, and set $value to its value + for ($i = 1, $len = count($bytes); $i < $len; $i++) { + $value = hexdec($bytes[$i]); + + // If we're the first byte of sequence: + if (!$remaining) { + // Start position + $start = $i; + + // By default we are valid + $valid = true; + + // One byte sequence: + if ($value <= 0x7F) { + $character = $value; + $length = 1; + } + // Two byte sequence: + elseif (($value & 0xE0) === 0xC0) { + $character = ($value & 0x1F) << 6; + $length = 2; + $remaining = 1; + } + // Three byte sequence: + elseif (($value & 0xF0) === 0xE0) { + $character = ($value & 0x0F) << 12; + $length = 3; + $remaining = 2; + } + // Four byte sequence: + elseif (($value & 0xF8) === 0xF0) { + $character = ($value & 0x07) << 18; + $length = 4; + $remaining = 3; + } + // Invalid byte: + else { + $valid = false; + $remaining = 0; + } + } + // Continuation byte: + else { + // Check that the byte is valid, then add it to the character: + if (($value & 0xC0) === 0x80) { + $remaining--; + $character |= ($value & 0x3F) << ($remaining * 6); + } + // If it is invalid, count the sequence as invalid and reprocess the current byte as the start of a sequence: + else { + $valid = false; + $remaining = 0; + $i--; + } + } + + // If we've reached the end of the current byte sequence, append it to Unicode::$data + if (!$remaining) { + // Percent encode anything invalid or not in iunreserved + if ( + // Invalid sequences + !$valid + // Non-shortest form sequences are invalid + || $length > 1 && $character <= 0x7F + || $length > 2 && $character <= 0x7FF + || $length > 3 && $character <= 0xFFFF + // Outside of range of iunreserved codepoints + || $character < 0x2D + || $character > 0xEFFFD + // Noncharacters + || ($character & 0xFFFE) === 0xFFFE + || $character >= 0xFDD0 && $character <= 0xFDEF + // Everything else not in iunreserved (this is all BMP) + || $character === 0x2F + || $character > 0x39 && $character < 0x41 + || $character > 0x5A && $character < 0x61 + || $character > 0x7A && $character < 0x7E + || $character > 0x7E && $character < 0xA0 + || $character > 0xD7FF && $character < 0xF900 + ) { + for ($j = $start; $j <= $i; $j++) { + $string .= '%' . strtoupper($bytes[$j]); + } + } + else { + for ($j = $start; $j <= $i; $j++) { + $string .= chr(hexdec($bytes[$j])); + } + } + } + } + + // If we have any bytes left over they are invalid (i.e., we are + // mid-way through a multi-byte sequence) + if ($remaining) { + for ($j = $start; $j < $len; $j++) { + $string .= '%' . strtoupper($bytes[$j]); + } + } + + return $string; + } + + protected function scheme_normalization() { + if (isset($this->normalization[$this->scheme]['iuserinfo']) && $this->iuserinfo === $this->normalization[$this->scheme]['iuserinfo']) { + $this->iuserinfo = null; + } + if (isset($this->normalization[$this->scheme]['ihost']) && $this->ihost === $this->normalization[$this->scheme]['ihost']) { + $this->ihost = null; + } + if (isset($this->normalization[$this->scheme]['port']) && $this->port === $this->normalization[$this->scheme]['port']) { + $this->port = null; + } + if (isset($this->normalization[$this->scheme]['ipath']) && $this->ipath === $this->normalization[$this->scheme]['ipath']) { + $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; + } + if (isset($this->normalization[$this->scheme]['ifragment']) && $this->ifragment === $this->normalization[$this->scheme]['ifragment']) { + $this->ifragment = null; + } + } + + /** + * Check if the object represents a valid IRI. This needs to be done on each + * call as some things change depending on another part of the IRI. + * + * @return bool + */ + public function is_valid() { + $isauthority = $this->iuserinfo !== null || $this->ihost !== null || $this->port !== null; + if ($this->ipath !== '' && + ( + $isauthority && $this->ipath[0] !== '/' || + ( + $this->scheme === null && + !$isauthority && + strpos($this->ipath, ':') !== false && + (strpos($this->ipath, '/') === false ? true : strpos($this->ipath, ':') < strpos($this->ipath, '/')) + ) + ) + ) { + return false; + } + + return true; + } + + public function __wakeup() { + $class_props = get_class_vars( __CLASS__ ); + $string_props = array( 'scheme', 'iuserinfo', 'ihost', 'port', 'ipath', 'iquery', 'ifragment' ); + $array_props = array( 'normalization' ); + foreach ( $class_props as $prop => $default_value ) { + if ( in_array( $prop, $string_props, true ) && ! is_string( $this->$prop ) ) { + throw new UnexpectedValueException(); + } elseif ( in_array( $prop, $array_props, true ) && ! is_array( $this->$prop ) ) { + throw new UnexpectedValueException(); + } + $this->$prop = null; + } + } + + /** + * Set the entire IRI. Returns true on success, false on failure (if there + * are any invalid characters). + * + * @param string $iri + * @return bool + */ + protected function set_iri($iri) { + static $cache; + if (!$cache) { + $cache = array(); + } + + if ($iri === null) { + return true; + } + + $iri = (string) $iri; + + if (isset($cache[$iri])) { + list($this->scheme, + $this->iuserinfo, + $this->ihost, + $this->port, + $this->ipath, + $this->iquery, + $this->ifragment, + $return) = $cache[$iri]; + return $return; + } + + $parsed = $this->parse_iri($iri); + + $return = $this->set_scheme($parsed['scheme']) + && $this->set_authority($parsed['authority']) + && $this->set_path($parsed['path']) + && $this->set_query($parsed['query']) + && $this->set_fragment($parsed['fragment']); + + $cache[$iri] = array($this->scheme, + $this->iuserinfo, + $this->ihost, + $this->port, + $this->ipath, + $this->iquery, + $this->ifragment, + $return); + return $return; + } + + /** + * Set the scheme. Returns true on success, false on failure (if there are + * any invalid characters). + * + * @param string $scheme + * @return bool + */ + protected function set_scheme($scheme) { + if ($scheme === null) { + $this->scheme = null; + } + elseif (!preg_match('/^[A-Za-z][0-9A-Za-z+\-.]*$/', $scheme)) { + $this->scheme = null; + return false; + } + else { + $this->scheme = strtolower($scheme); + } + return true; + } + + /** + * Set the authority. Returns true on success, false on failure (if there are + * any invalid characters). + * + * @param string $authority + * @return bool + */ + protected function set_authority($authority) { + static $cache; + if (!$cache) { + $cache = array(); + } + + if ($authority === null) { + $this->iuserinfo = null; + $this->ihost = null; + $this->port = null; + return true; + } + if (isset($cache[$authority])) { + list($this->iuserinfo, + $this->ihost, + $this->port, + $return) = $cache[$authority]; + + return $return; + } + + $remaining = $authority; + if (($iuserinfo_end = strrpos($remaining, '@')) !== false) { + $iuserinfo = substr($remaining, 0, $iuserinfo_end); + $remaining = substr($remaining, $iuserinfo_end + 1); + } + else { + $iuserinfo = null; + } + + if (($port_start = strpos($remaining, ':', (strpos($remaining, ']') ?: 0))) !== false) { + $port = substr($remaining, $port_start + 1); + if ($port === false || $port === '') { + $port = null; + } + $remaining = substr($remaining, 0, $port_start); + } + else { + $port = null; + } + + $return = $this->set_userinfo($iuserinfo) && + $this->set_host($remaining) && + $this->set_port($port); + + $cache[$authority] = array($this->iuserinfo, + $this->ihost, + $this->port, + $return); + + return $return; + } + + /** + * Set the iuserinfo. + * + * @param string $iuserinfo + * @return bool + */ + protected function set_userinfo($iuserinfo) { + if ($iuserinfo === null) { + $this->iuserinfo = null; + } + else { + $this->iuserinfo = $this->replace_invalid_with_pct_encoding($iuserinfo, '!$&\'()*+,;=:'); + $this->scheme_normalization(); + } + + return true; + } + + /** + * Set the ihost. Returns true on success, false on failure (if there are + * any invalid characters). + * + * @param string $ihost + * @return bool + */ + protected function set_host($ihost) { + if ($ihost === null) { + $this->ihost = null; + return true; + } + if (substr($ihost, 0, 1) === '[' && substr($ihost, -1) === ']') { + if (Ipv6::check_ipv6(substr($ihost, 1, -1))) { + $this->ihost = '[' . Ipv6::compress(substr($ihost, 1, -1)) . ']'; + } + else { + $this->ihost = null; + return false; + } + } + else { + $ihost = $this->replace_invalid_with_pct_encoding($ihost, '!$&\'()*+,;='); + + // Lowercase, but ignore pct-encoded sections (as they should + // remain uppercase). This must be done after the previous step + // as that can add unescaped characters. + $position = 0; + $strlen = strlen($ihost); + while (($position += strcspn($ihost, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ%', $position)) < $strlen) { + if ($ihost[$position] === '%') { + $position += 3; + } + else { + $ihost[$position] = strtolower($ihost[$position]); + $position++; + } + } + + $this->ihost = $ihost; + } + + $this->scheme_normalization(); + + return true; + } + + /** + * Set the port. Returns true on success, false on failure (if there are + * any invalid characters). + * + * @param string $port + * @return bool + */ + protected function set_port($port) { + if ($port === null) { + $this->port = null; + return true; + } + + if (strspn($port, '0123456789') === strlen($port)) { + $this->port = (int) $port; + $this->scheme_normalization(); + return true; + } + + $this->port = null; + return false; + } + + /** + * Set the ipath. + * + * @param string $ipath + * @return bool + */ + protected function set_path($ipath) { + static $cache; + if (!$cache) { + $cache = array(); + } + + $ipath = (string) $ipath; + + if (isset($cache[$ipath])) { + $this->ipath = $cache[$ipath][(int) ($this->scheme !== null)]; + } + else { + $valid = $this->replace_invalid_with_pct_encoding($ipath, '!$&\'()*+,;=@:/'); + $removed = $this->remove_dot_segments($valid); + + $cache[$ipath] = array($valid, $removed); + $this->ipath = ($this->scheme !== null) ? $removed : $valid; + } + $this->scheme_normalization(); + return true; + } + + /** + * Set the iquery. + * + * @param string $iquery + * @return bool + */ + protected function set_query($iquery) { + if ($iquery === null) { + $this->iquery = null; + } + else { + $this->iquery = $this->replace_invalid_with_pct_encoding($iquery, '!$&\'()*+,;=:@/?', true); + $this->scheme_normalization(); + } + return true; + } + + /** + * Set the ifragment. + * + * @param string $ifragment + * @return bool + */ + protected function set_fragment($ifragment) { + if ($ifragment === null) { + $this->ifragment = null; + } + else { + $this->ifragment = $this->replace_invalid_with_pct_encoding($ifragment, '!$&\'()*+,;=:@/?'); + $this->scheme_normalization(); + } + return true; + } + + /** + * Convert an IRI to a URI (or parts thereof) + * + * @param string|bool $iri IRI to convert (or false from {@see \WpOrg\Requests\Iri::get_iri()}) + * @return string|false URI if IRI is valid, false otherwise. + */ + protected function to_uri($iri) { + if (!is_string($iri)) { + return false; + } + + static $non_ascii; + if (!$non_ascii) { + $non_ascii = implode('', range("\x80", "\xFF")); + } + + $position = 0; + $strlen = strlen($iri); + while (($position += strcspn($iri, $non_ascii, $position)) < $strlen) { + $iri = substr_replace($iri, sprintf('%%%02X', ord($iri[$position])), $position, 1); + $position += 3; + $strlen += 2; + } + + return $iri; + } + + /** + * Get the complete IRI + * + * @return string|false + */ + protected function get_iri() { + if (!$this->is_valid()) { + return false; + } + + $iri = ''; + if ($this->scheme !== null) { + $iri .= $this->scheme . ':'; + } + if (($iauthority = $this->get_iauthority()) !== null) { + $iri .= '//' . $iauthority; + } + $iri .= $this->ipath; + if ($this->iquery !== null) { + $iri .= '?' . $this->iquery; + } + if ($this->ifragment !== null) { + $iri .= '#' . $this->ifragment; + } + + return $iri; + } + + /** + * Get the complete URI + * + * @return string + */ + protected function get_uri() { + return $this->to_uri($this->get_iri()); + } + + /** + * Get the complete iauthority + * + * @return string|null + */ + protected function get_iauthority() { + if ($this->iuserinfo === null && $this->ihost === null && $this->port === null) { + return null; + } + + $iauthority = ''; + if ($this->iuserinfo !== null) { + $iauthority .= $this->iuserinfo . '@'; + } + if ($this->ihost !== null) { + $iauthority .= $this->ihost; + } + if ($this->port !== null) { + $iauthority .= ':' . $this->port; + } + return $iauthority; + } + + /** + * Get the complete authority + * + * @return string + */ + protected function get_authority() { + $iauthority = $this->get_iauthority(); + if (is_string($iauthority)) { + return $this->to_uri($iauthority); + } + else { + return $iauthority; + } + } +} diff --git a/wp-includes/Requests/src/Port.php b/wp-includes/Requests/src/Port.php new file mode 100644 index 0000000..5545409 --- /dev/null +++ b/wp-includes/Requests/src/Port.php @@ -0,0 +1,75 @@ +proxy = $args; + } elseif (is_array($args)) { + if (count($args) === 1) { + list($this->proxy) = $args; + } elseif (count($args) === 3) { + list($this->proxy, $this->user, $this->pass) = $args; + $this->use_authentication = true; + } else { + throw ArgumentCount::create( + 'an array with exactly one element or exactly three elements', + count($args), + 'proxyhttpbadargs' + ); + } + } elseif ($args !== null) { + throw InvalidArgument::create(1, '$args', 'array|string|null', gettype($args)); + } + } + + /** + * Register the necessary callbacks + * + * @since 1.6 + * @see \WpOrg\Requests\Proxy\Http::curl_before_send() + * @see \WpOrg\Requests\Proxy\Http::fsockopen_remote_socket() + * @see \WpOrg\Requests\Proxy\Http::fsockopen_remote_host_path() + * @see \WpOrg\Requests\Proxy\Http::fsockopen_header() + * @param \WpOrg\Requests\Hooks $hooks Hook system + */ + public function register(Hooks $hooks) { + $hooks->register('curl.before_send', [$this, 'curl_before_send']); + + $hooks->register('fsockopen.remote_socket', [$this, 'fsockopen_remote_socket']); + $hooks->register('fsockopen.remote_host_path', [$this, 'fsockopen_remote_host_path']); + if ($this->use_authentication) { + $hooks->register('fsockopen.after_headers', [$this, 'fsockopen_header']); + } + } + + /** + * Set cURL parameters before the data is sent + * + * @since 1.6 + * @param resource|\CurlHandle $handle cURL handle + */ + public function curl_before_send(&$handle) { + curl_setopt($handle, CURLOPT_PROXYTYPE, CURLPROXY_HTTP); + curl_setopt($handle, CURLOPT_PROXY, $this->proxy); + + if ($this->use_authentication) { + curl_setopt($handle, CURLOPT_PROXYAUTH, CURLAUTH_ANY); + curl_setopt($handle, CURLOPT_PROXYUSERPWD, $this->get_auth_string()); + } + } + + /** + * Alter remote socket information before opening socket connection + * + * @since 1.6 + * @param string $remote_socket Socket connection string + */ + public function fsockopen_remote_socket(&$remote_socket) { + $remote_socket = $this->proxy; + } + + /** + * Alter remote path before getting stream data + * + * @since 1.6 + * @param string $path Path to send in HTTP request string ("GET ...") + * @param string $url Full URL we're requesting + */ + public function fsockopen_remote_host_path(&$path, $url) { + $path = $url; + } + + /** + * Add extra headers to the request before sending + * + * @since 1.6 + * @param string $out HTTP header string + */ + public function fsockopen_header(&$out) { + $out .= sprintf("Proxy-Authorization: Basic %s\r\n", base64_encode($this->get_auth_string())); + } + + /** + * Get the authentication string (user:pass) + * + * @since 1.6 + * @return string + */ + public function get_auth_string() { + return $this->user . ':' . $this->pass; + } +} diff --git a/wp-includes/Requests/src/Requests.php b/wp-includes/Requests/src/Requests.php new file mode 100644 index 0000000..bb5292a --- /dev/null +++ b/wp-includes/Requests/src/Requests.php @@ -0,0 +1,1099 @@ + 10, + 'connect_timeout' => 10, + 'useragent' => 'php-requests/' . self::VERSION, + 'protocol_version' => 1.1, + 'redirected' => 0, + 'redirects' => 10, + 'follow_redirects' => true, + 'blocking' => true, + 'type' => self::GET, + 'filename' => false, + 'auth' => false, + 'proxy' => false, + 'cookies' => false, + 'max_bytes' => false, + 'idn' => true, + 'hooks' => null, + 'transport' => null, + 'verify' => null, + 'verifyname' => true, + ]; + + /** + * Default supported Transport classes. + * + * @since 2.0.0 + * + * @var array + */ + const DEFAULT_TRANSPORTS = [ + Curl::class => Curl::class, + Fsockopen::class => Fsockopen::class, + ]; + + /** + * Current version of Requests + * + * @var string + */ + const VERSION = '2.0.9'; + + /** + * Selected transport name + * + * Use {@see \WpOrg\Requests\Requests::get_transport()} instead + * + * @var array + */ + public static $transport = []; + + /** + * Registered transport classes + * + * @var array + */ + protected static $transports = []; + + /** + * Default certificate path. + * + * @see \WpOrg\Requests\Requests::get_certificate_path() + * @see \WpOrg\Requests\Requests::set_certificate_path() + * + * @var string + */ + protected static $certificate_path = __DIR__ . '/../certificates/cacert.pem'; + + /** + * All (known) valid deflate, gzip header magic markers. + * + * These markers relate to different compression levels. + * + * @link https://stackoverflow.com/a/43170354/482864 Marker source. + * + * @since 2.0.0 + * + * @var array + */ + private static $magic_compression_headers = [ + "\x1f\x8b" => true, // Gzip marker. + "\x78\x01" => true, // Zlib marker - level 1. + "\x78\x5e" => true, // Zlib marker - level 2 to 5. + "\x78\x9c" => true, // Zlib marker - level 6. + "\x78\xda" => true, // Zlib marker - level 7 to 9. + ]; + + /** + * This is a static class, do not instantiate it + * + * @codeCoverageIgnore + */ + private function __construct() {} + + /** + * Register a transport + * + * @param string $transport Transport class to add, must support the \WpOrg\Requests\Transport interface + */ + public static function add_transport($transport) { + if (empty(self::$transports)) { + self::$transports = self::DEFAULT_TRANSPORTS; + } + + self::$transports[$transport] = $transport; + } + + /** + * Get the fully qualified class name (FQCN) for a working transport. + * + * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. + * @return string FQCN of the transport to use, or an empty string if no transport was + * found which provided the requested capabilities. + */ + protected static function get_transport_class(array $capabilities = []) { + // Caching code, don't bother testing coverage. + // @codeCoverageIgnoreStart + // 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])) { + return self::$transport[$cap_string]; + } + + // Ensure we will not run this same check again later on. + self::$transport[$cap_string] = ''; + // @codeCoverageIgnoreEnd + + if (empty(self::$transports)) { + self::$transports = self::DEFAULT_TRANSPORTS; + } + + // Find us a working transport. + foreach (self::$transports as $class) { + if (!class_exists($class)) { + continue; + } + + $result = $class::test($capabilities); + if ($result === true) { + self::$transport[$cap_string] = $class; + break; + } + } + + return self::$transport[$cap_string]; + } + + /** + * Get a working transport. + * + * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. + * @return \WpOrg\Requests\Transport + * @throws \WpOrg\Requests\Exception If no valid transport is found (`notransport`). + */ + protected static function get_transport(array $capabilities = []) { + $class = self::get_transport_class($capabilities); + + if ($class === '') { + throw new Exception('No working transports found', 'notransport', self::$transports); + } + + return new $class(); + } + + /** + * Checks to see if we have a transport for the capabilities requested. + * + * Supported capabilities can be found in the {@see \WpOrg\Requests\Capability} + * interface as constants. + * + * Example usage: + * `Requests::has_capabilities([Capability::SSL => true])`. + * + * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. + * @return bool Whether the transport has the requested capabilities. + */ + public static function has_capabilities(array $capabilities = []) { + return self::get_transport_class($capabilities) !== ''; + } + + /**#@+ + * @see \WpOrg\Requests\Requests::request() + * @param string $url + * @param array $headers + * @param array $options + * @return \WpOrg\Requests\Response + */ + /** + * Send a GET request + */ + public static function get($url, $headers = [], $options = []) { + return self::request($url, $headers, null, self::GET, $options); + } + + /** + * Send a HEAD request + */ + public static function head($url, $headers = [], $options = []) { + return self::request($url, $headers, null, self::HEAD, $options); + } + + /** + * Send a DELETE request + */ + public static function delete($url, $headers = [], $options = []) { + return self::request($url, $headers, null, self::DELETE, $options); + } + + /** + * Send a TRACE request + */ + public static function trace($url, $headers = [], $options = []) { + return self::request($url, $headers, null, self::TRACE, $options); + } + /**#@-*/ + + /**#@+ + * @see \WpOrg\Requests\Requests::request() + * @param string $url + * @param array $headers + * @param array $data + * @param array $options + * @return \WpOrg\Requests\Response + */ + /** + * Send a POST request + */ + public static function post($url, $headers = [], $data = [], $options = []) { + return self::request($url, $headers, $data, self::POST, $options); + } + /** + * Send a PUT request + */ + public static function put($url, $headers = [], $data = [], $options = []) { + return self::request($url, $headers, $data, self::PUT, $options); + } + + /** + * Send an OPTIONS request + */ + public static function options($url, $headers = [], $data = [], $options = []) { + return self::request($url, $headers, $data, self::OPTIONS, $options); + } + + /** + * Send a PATCH request + * + * Note: Unlike {@see \WpOrg\Requests\Requests::post()} and {@see \WpOrg\Requests\Requests::put()}, + * `$headers` is required, as the specification recommends that should send an ETag + * + * @link https://tools.ietf.org/html/rfc5789 + */ + public static function patch($url, $headers, $data = [], $options = []) { + return self::request($url, $headers, $data, self::PATCH, $options); + } + /**#@-*/ + + /** + * Main interface for HTTP requests + * + * This method initiates a request and sends it via a transport before + * parsing. + * + * The `$options` parameter takes an associative array with the following + * options: + * + * - `timeout`: How long should we wait for a response? + * Note: for cURL, a minimum of 1 second applies, as DNS resolution + * operates at second-resolution only. + * (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? + * (boolean, default: true) + * - `redirects`: How many times should we redirect before erroring? + * (integer, default: 10) + * - `blocking`: Should we block processing on this request? + * (boolean, default: true) + * - `filename`: File to stream the body to instead. + * (string|boolean, default: false) + * - `auth`: Authentication handler or array of user/password details to use + * for Basic authentication + * (\WpOrg\Requests\Auth|array|boolean, default: false) + * - `proxy`: Proxy details to use for proxy by-passing and authentication + * (\WpOrg\Requests\Proxy|array|string|boolean, default: false) + * - `max_bytes`: Limit for the response body size. + * (integer|boolean, default: false) + * - `idn`: Enable IDN parsing + * (boolean, default: true) + * - `transport`: Custom transport. Either a class name, or a + * transport object. Defaults to the first working transport from + * {@see \WpOrg\Requests\Requests::getTransport()} + * (string|\WpOrg\Requests\Transport, default: {@see \WpOrg\Requests\Requests::getTransport()}) + * - `hooks`: Hooks handler. + * (\WpOrg\Requests\HookManager, default: new WpOrg\Requests\Hooks()) + * - `verify`: Should we verify SSL certificates? Allows passing in a custom + * certificate file as a string. (Using true uses the system-wide root + * certificate store instead, but this may have different behaviour + * across transports.) + * (string|boolean, default: certificates/cacert.pem) + * - `verifyname`: Should we verify the common name in the SSL certificate? + * (boolean, default: true) + * - `data_format`: How should we send the `$data` parameter? + * (string, one of 'query' or 'body', default: 'query' for + * HEAD/GET/DELETE, 'body' for POST/PUT/OPTIONS/PATCH) + * + * @param string|Stringable $url URL to request + * @param array $headers Extra headers to send with the request + * @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests + * @param string $type HTTP request type (use Requests constants) + * @param array $options Options for the request (see description for more information) + * @return \WpOrg\Requests\Response + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $type argument is not a string. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + * @throws \WpOrg\Requests\Exception On invalid URLs (`nonhttp`) + */ + public static function request($url, $headers = [], $data = [], $type = self::GET, $options = []) { + if (InputValidator::is_string_or_stringable($url) === false) { + throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url)); + } + + if (is_string($type) === false) { + throw InvalidArgument::create(4, '$type', 'string', gettype($type)); + } + + if (is_array($options) === false) { + throw InvalidArgument::create(5, '$options', 'array', gettype($options)); + } + + if (empty($options['type'])) { + $options['type'] = $type; + } + + $options = array_merge(self::get_default_options(), $options); + + self::set_defaults($url, $headers, $data, $type, $options); + + $options['hooks']->dispatch('requests.before_request', [&$url, &$headers, &$data, &$type, &$options]); + + if (!empty($options['transport'])) { + $transport = $options['transport']; + + if (is_string($options['transport'])) { + $transport = new $transport(); + } + } else { + $need_ssl = (stripos($url, 'https://') === 0); + $capabilities = [Capability::SSL => $need_ssl]; + $transport = self::get_transport($capabilities); + } + + $response = $transport->request($url, $headers, $data, $options); + + $options['hooks']->dispatch('requests.before_parse', [&$response, $url, $headers, $data, $type, $options]); + + return self::parse_response($response, $url, $headers, $data, $options); + } + + /** + * Send multiple HTTP requests simultaneously + * + * The `$requests` parameter takes an associative or indexed array of + * request fields. The key of each request can be used to match up the + * request with the returned data, or with the request passed into your + * `multiple.request.complete` callback. + * + * The request fields value is an associative array with the following keys: + * + * - `url`: Request URL Same as the `$url` parameter to + * {@see \WpOrg\Requests\Requests::request()} + * (string, required) + * - `headers`: Associative array of header fields. Same as the `$headers` + * parameter to {@see \WpOrg\Requests\Requests::request()} + * (array, default: `array()`) + * - `data`: Associative array of data fields or a string. Same as the + * `$data` parameter to {@see \WpOrg\Requests\Requests::request()} + * (array|string, default: `array()`) + * - `type`: HTTP request type (use \WpOrg\Requests\Requests constants). Same as the `$type` + * parameter to {@see \WpOrg\Requests\Requests::request()} + * (string, default: `\WpOrg\Requests\Requests::GET`) + * - `cookies`: Associative array of cookie name to value, or cookie jar. + * (array|\WpOrg\Requests\Cookie\Jar) + * + * If the `$options` parameter is specified, individual requests will + * inherit options from it. This can be used to use a single hooking system, + * or set all the types to `\WpOrg\Requests\Requests::POST`, for example. + * + * In addition, the `$options` parameter takes the following global options: + * + * - `complete`: A callback for when a request is complete. Takes two + * parameters, a \WpOrg\Requests\Response/\WpOrg\Requests\Exception reference, and the + * ID from the request array (Note: this can also be overridden on a + * per-request basis, although that's a little silly) + * (callback) + * + * @param array $requests Requests data (see description for more information) + * @param array $options Global and default options (see {@see \WpOrg\Requests\Requests::request()}) + * @return array Responses (either \WpOrg\Requests\Response or a \WpOrg\Requests\Exception object) + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + */ + public static function request_multiple($requests, $options = []) { + if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { + throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); + } + + if (is_array($options) === false) { + throw InvalidArgument::create(2, '$options', 'array', gettype($options)); + } + + $options = array_merge(self::get_default_options(true), $options); + + if (!empty($options['hooks'])) { + $options['hooks']->register('transport.internal.parse_response', [static::class, 'parse_multiple']); + if (!empty($options['complete'])) { + $options['hooks']->register('multiple.request.complete', $options['complete']); + } + } + + foreach ($requests as $id => &$request) { + if (!isset($request['headers'])) { + $request['headers'] = []; + } + + if (!isset($request['data'])) { + $request['data'] = []; + } + + if (!isset($request['type'])) { + $request['type'] = self::GET; + } + + if (!isset($request['options'])) { + $request['options'] = $options; + $request['options']['type'] = $request['type']; + } else { + if (empty($request['options']['type'])) { + $request['options']['type'] = $request['type']; + } + + $request['options'] = array_merge($options, $request['options']); + } + + self::set_defaults($request['url'], $request['headers'], $request['data'], $request['type'], $request['options']); + + // Ensure we only hook in once + if ($request['options']['hooks'] !== $options['hooks']) { + $request['options']['hooks']->register('transport.internal.parse_response', [static::class, 'parse_multiple']); + if (!empty($request['options']['complete'])) { + $request['options']['hooks']->register('multiple.request.complete', $request['options']['complete']); + } + } + } + + unset($request); + + if (!empty($options['transport'])) { + $transport = $options['transport']; + + if (is_string($options['transport'])) { + $transport = new $transport(); + } + } else { + $transport = self::get_transport(); + } + + $responses = $transport->request_multiple($requests, $options); + + foreach ($responses as $id => &$response) { + // If our hook got messed with somehow, ensure we end up with the + // correct response + if (is_string($response)) { + $request = $requests[$id]; + self::parse_multiple($response, $request); + $request['options']['hooks']->dispatch('multiple.request.complete', [&$response, $id]); + } + } + + return $responses; + } + + /** + * Get the default options + * + * @see \WpOrg\Requests\Requests::request() for values returned by this method + * @param boolean $multirequest Is this a multirequest? + * @return array Default option values + */ + protected static function get_default_options($multirequest = false) { + $defaults = static::OPTION_DEFAULTS; + $defaults['verify'] = self::$certificate_path; + + if ($multirequest !== false) { + $defaults['complete'] = null; + } + + return $defaults; + } + + /** + * Get default certificate path. + * + * @return string Default certificate path. + */ + public static function get_certificate_path() { + return self::$certificate_path; + } + + /** + * Set default certificate path. + * + * @param string|Stringable|bool $path Certificate path, pointing to a PEM file. + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string, Stringable or boolean. + */ + public static function set_certificate_path($path) { + if (InputValidator::is_string_or_stringable($path) === false && is_bool($path) === false) { + throw InvalidArgument::create(1, '$path', 'string|Stringable|bool', gettype($path)); + } + + self::$certificate_path = $path; + } + + /** + * Set the default values + * + * The $options parameter is updated with the results. + * + * @param string $url URL to request + * @param array $headers Extra headers to send with the request + * @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests + * @param string $type HTTP request type + * @param array $options Options for the request + * @return void + * + * @throws \WpOrg\Requests\Exception When the $url is not an http(s) URL. + */ + protected static function set_defaults(&$url, &$headers, &$data, &$type, &$options) { + if (!preg_match('/^http(s)?:\/\//i', $url, $matches)) { + throw new Exception('Only HTTP(S) requests are handled.', 'nonhttp', $url); + } + + if (empty($options['hooks'])) { + $options['hooks'] = new Hooks(); + } + + if (is_array($options['auth'])) { + $options['auth'] = new Basic($options['auth']); + } + + if ($options['auth'] !== false) { + $options['auth']->register($options['hooks']); + } + + if (is_string($options['proxy']) || is_array($options['proxy'])) { + $options['proxy'] = new Http($options['proxy']); + } + + if ($options['proxy'] !== false) { + $options['proxy']->register($options['hooks']); + } + + if (is_array($options['cookies'])) { + $options['cookies'] = new Jar($options['cookies']); + } elseif (empty($options['cookies'])) { + $options['cookies'] = new Jar(); + } + + if ($options['cookies'] !== false) { + $options['cookies']->register($options['hooks']); + } + + if ($options['idn'] !== false) { + $iri = new Iri($url); + $iri->host = IdnaEncoder::encode($iri->ihost); + $url = $iri->uri; + } + + // Massage the type to ensure we support it. + $type = strtoupper($type); + + if (!isset($options['data_format'])) { + if (in_array($type, [self::HEAD, self::GET, self::DELETE], true)) { + $options['data_format'] = 'query'; + } else { + $options['data_format'] = 'body'; + } + } + } + + /** + * HTTP response parser + * + * @param string $headers Full response text including headers and body + * @param string $url Original request URL + * @param array $req_headers Original $headers array passed to {@link request()}, in case we need to follow redirects + * @param array $req_data Original $data array passed to {@link request()}, in case we need to follow redirects + * @param array $options Original $options array passed to {@link request()}, in case we need to follow redirects + * @return \WpOrg\Requests\Response + * + * @throws \WpOrg\Requests\Exception On missing head/body separator (`requests.no_crlf_separator`) + * @throws \WpOrg\Requests\Exception On missing head/body separator (`noversion`) + * @throws \WpOrg\Requests\Exception On missing head/body separator (`toomanyredirects`) + */ + protected static function parse_response($headers, $url, $req_headers, $req_data, $options) { + $return = new Response(); + if (!$options['blocking']) { + return $return; + } + + $return->raw = $headers; + $return->url = (string) $url; + $return->body = ''; + + if (!$options['filename']) { + $pos = strpos($headers, "\r\n\r\n"); + if ($pos === false) { + // Crap! + throw new Exception('Missing header/body separator', 'requests.no_crlf_separator'); + } + + $headers = substr($return->raw, 0, $pos); + // Headers will always be separated from the body by two new lines - `\n\r\n\r`. + $body = substr($return->raw, $pos + 4); + if (!empty($body)) { + $return->body = $body; + } + } + + // Pretend CRLF = LF for compatibility (RFC 2616, section 19.3) + $headers = str_replace("\r\n", "\n", $headers); + // Unfold headers (replace [CRLF] 1*( SP | HT ) with SP) as per RFC 2616 (section 2.2) + $headers = preg_replace('/\n[ \t]/', ' ', $headers); + $headers = explode("\n", $headers); + preg_match('#^HTTP/(1\.\d)[ \t]+(\d+)#i', array_shift($headers), $matches); + if (empty($matches)) { + throw new Exception('Response could not be parsed', 'noversion', $headers); + } + + $return->protocol_version = (float) $matches[1]; + $return->status_code = (int) $matches[2]; + if ($return->status_code >= 200 && $return->status_code < 300) { + $return->success = true; + } + + foreach ($headers as $header) { + list($key, $value) = explode(':', $header, 2); + $value = trim($value); + preg_replace('#(\s+)#i', ' ', $value); + $return->headers[$key] = $value; + } + + if (isset($return->headers['transfer-encoding'])) { + $return->body = self::decode_chunked($return->body); + unset($return->headers['transfer-encoding']); + } + + if (isset($return->headers['content-encoding'])) { + $return->body = self::decompress($return->body); + } + + //fsockopen and cURL compatibility + if (isset($return->headers['connection'])) { + unset($return->headers['connection']); + } + + $options['hooks']->dispatch('requests.before_redirect_check', [&$return, $req_headers, $req_data, $options]); + + if ($return->is_redirect() && $options['follow_redirects'] === true) { + if (isset($return->headers['location']) && $options['redirected'] < $options['redirects']) { + if ($return->status_code === 303) { + $options['type'] = self::GET; + } + + $options['redirected']++; + $location = $return->headers['location']; + if (strpos($location, 'http://') !== 0 && strpos($location, 'https://') !== 0) { + // relative redirect, for compatibility make it absolute + $location = Iri::absolutize($url, $location); + $location = $location->uri; + } + + $hook_args = [ + &$location, + &$req_headers, + &$req_data, + &$options, + $return, + ]; + $options['hooks']->dispatch('requests.before_redirect', $hook_args); + $redirected = self::request($location, $req_headers, $req_data, $options['type'], $options); + $redirected->history[] = $return; + return $redirected; + } elseif ($options['redirected'] >= $options['redirects']) { + throw new Exception('Too many redirects', 'toomanyredirects', $return); + } + } + + $return->redirects = $options['redirected']; + + $options['hooks']->dispatch('requests.after_request', [&$return, $req_headers, $req_data, $options]); + return $return; + } + + /** + * Callback for `transport.internal.parse_response` + * + * Internal use only. Converts a raw HTTP response to a \WpOrg\Requests\Response + * while still executing a multiple request. + * + * `$response` is either set to a \WpOrg\Requests\Response instance, or a \WpOrg\Requests\Exception object + * + * @param string $response Full response text including headers and body (will be overwritten with Response instance) + * @param array $request Request data as passed into {@see \WpOrg\Requests\Requests::request_multiple()} + * @return void + */ + public static function parse_multiple(&$response, $request) { + try { + $url = $request['url']; + $headers = $request['headers']; + $data = $request['data']; + $options = $request['options']; + $response = self::parse_response($response, $url, $headers, $data, $options); + } catch (Exception $e) { + $response = $e; + } + } + + /** + * Decoded a chunked body as per RFC 2616 + * + * @link https://tools.ietf.org/html/rfc2616#section-3.6.1 + * @param string $data Chunked body + * @return string Decoded body + */ + protected static function decode_chunked($data) { + if (!preg_match('/^([0-9a-f]+)(?:;(?:[\w-]*)(?:=(?:(?:[\w-]*)*|"(?:[^\r\n])*"))?)*\r\n/i', trim($data))) { + return $data; + } + + $decoded = ''; + $encoded = $data; + + while (true) { + $is_chunked = (bool) preg_match('/^([0-9a-f]+)(?:;(?:[\w-]*)(?:=(?:(?:[\w-]*)*|"(?:[^\r\n])*"))?)*\r\n/i', $encoded, $matches); + if (!$is_chunked) { + // Looks like it's not chunked after all + return $data; + } + + $length = hexdec(trim($matches[1])); + if ($length === 0) { + // Ignore trailer headers + return $decoded; + } + + $chunk_length = strlen($matches[0]); + $decoded .= substr($encoded, $chunk_length, $length); + $encoded = substr($encoded, $chunk_length + $length + 2); + + if (trim($encoded) === '0' || empty($encoded)) { + return $decoded; + } + } + + // We'll never actually get down here + // @codeCoverageIgnoreStart + } + // @codeCoverageIgnoreEnd + + /** + * Convert a key => value array to a 'key: value' array for headers + * + * @param iterable $dictionary Dictionary of header values + * @return array List of headers + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not iterable. + */ + public static function flatten($dictionary) { + if (InputValidator::is_iterable($dictionary) === false) { + throw InvalidArgument::create(1, '$dictionary', 'iterable', gettype($dictionary)); + } + + $return = []; + foreach ($dictionary as $key => $value) { + $return[] = sprintf('%s: %s', $key, $value); + } + + return $return; + } + + /** + * Decompress an encoded body + * + * Implements gzip, compress and deflate. Guesses which it is by attempting + * to decode. + * + * @param string $data Compressed data in one of the above formats + * @return string Decompressed string + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string. + */ + public static function decompress($data) { + if (is_string($data) === false) { + throw InvalidArgument::create(1, '$data', 'string', gettype($data)); + } + + if (trim($data) === '') { + // Empty body does not need further processing. + return $data; + } + + $marker = substr($data, 0, 2); + if (!isset(self::$magic_compression_headers[$marker])) { + // Not actually compressed. Probably cURL ruining this for us. + return $data; + } + + if (function_exists('gzdecode')) { + $decoded = @gzdecode($data); + if ($decoded !== false) { + return $decoded; + } + } + + if (function_exists('gzinflate')) { + $decoded = @gzinflate($data); + if ($decoded !== false) { + return $decoded; + } + } + + $decoded = self::compatible_gzinflate($data); + if ($decoded !== false) { + return $decoded; + } + + if (function_exists('gzuncompress')) { + $decoded = @gzuncompress($data); + if ($decoded !== false) { + return $decoded; + } + } + + return $data; + } + + /** + * Decompression of deflated string while staying compatible with the majority of servers. + * + * Certain Servers will return deflated data with headers which PHP's gzinflate() + * function cannot handle out of the box. The following function has been created from + * various snippets on the gzinflate() PHP documentation. + * + * Warning: Magic numbers within. Due to the potential different formats that the compressed + * data may be returned in, some "magic offsets" are needed to ensure proper decompression + * takes place. For a simple progmatic way to determine the magic offset in use, see: + * https://core.trac.wordpress.org/ticket/18273 + * + * @since 1.6.0 + * @link https://core.trac.wordpress.org/ticket/18273 + * @link https://www.php.net/gzinflate#70875 + * @link https://www.php.net/gzinflate#77336 + * + * @param string $gz_data String to decompress. + * @return string|bool False on failure. + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string. + */ + public static function compatible_gzinflate($gz_data) { + if (is_string($gz_data) === false) { + throw InvalidArgument::create(1, '$gz_data', 'string', gettype($gz_data)); + } + + if (trim($gz_data) === '') { + return false; + } + + // Compressed data might contain a full zlib header, if so strip it for + // gzinflate() + if (substr($gz_data, 0, 3) === "\x1f\x8b\x08") { + $i = 10; + $flg = ord(substr($gz_data, 3, 1)); + if ($flg > 0) { + if ($flg & 4) { + list($xlen) = unpack('v', substr($gz_data, $i, 2)); + $i += 2 + $xlen; + } + + if ($flg & 8) { + $i = strpos($gz_data, "\0", $i) + 1; + } + + if ($flg & 16) { + $i = strpos($gz_data, "\0", $i) + 1; + } + + if ($flg & 2) { + $i += 2; + } + } + + $decompressed = self::compatible_gzinflate(substr($gz_data, $i)); + if ($decompressed !== false) { + return $decompressed; + } + } + + // If the data is Huffman Encoded, we must first strip the leading 2 + // byte Huffman marker for gzinflate() + // The response is Huffman coded by many compressors such as + // java.util.zip.Deflater, Ruby's Zlib::Deflate, and .NET's + // System.IO.Compression.DeflateStream. + // + // See https://decompres.blogspot.com/ for a quick explanation of this + // data type + $huffman_encoded = false; + + // low nibble of first byte should be 0x08 + list(, $first_nibble) = unpack('h', $gz_data); + + // First 2 bytes should be divisible by 0x1F + list(, $first_two_bytes) = unpack('n', $gz_data); + + if ($first_nibble === 0x08 && ($first_two_bytes % 0x1F) === 0) { + $huffman_encoded = true; + } + + if ($huffman_encoded) { + $decompressed = @gzinflate(substr($gz_data, 2)); + if ($decompressed !== false) { + return $decompressed; + } + } + + if (substr($gz_data, 0, 4) === "\x50\x4b\x03\x04") { + // ZIP file format header + // Offset 6: 2 bytes, General-purpose field + // Offset 26: 2 bytes, filename length + // Offset 28: 2 bytes, optional field length + // Offset 30: Filename field, followed by optional field, followed + // immediately by data + list(, $general_purpose_flag) = unpack('v', substr($gz_data, 6, 2)); + + // If the file has been compressed on the fly, 0x08 bit is set of + // the general purpose field. We can use this to differentiate + // between a compressed document, and a ZIP file + $zip_compressed_on_the_fly = ((0x08 & $general_purpose_flag) === 0x08); + + if (!$zip_compressed_on_the_fly) { + // Don't attempt to decode a compressed zip file + return $gz_data; + } + + // Determine the first byte of data, based on the above ZIP header + // offsets: + $first_file_start = array_sum(unpack('v2', substr($gz_data, 26, 4))); + $decompressed = @gzinflate(substr($gz_data, 30 + $first_file_start)); + if ($decompressed !== false) { + return $decompressed; + } + + return false; + } + + // Finally fall back to straight gzinflate + $decompressed = @gzinflate($gz_data); + if ($decompressed !== false) { + return $decompressed; + } + + // Fallback for all above failing, not expected, but included for + // debugging and preventing regressions and to track stats + $decompressed = @gzinflate(substr($gz_data, 2)); + if ($decompressed !== false) { + return $decompressed; + } + + return false; + } +} diff --git a/wp-includes/Requests/src/Response.php b/wp-includes/Requests/src/Response.php new file mode 100644 index 0000000..86a0438 --- /dev/null +++ b/wp-includes/Requests/src/Response.php @@ -0,0 +1,165 @@ +headers = new Headers(); + $this->cookies = new Jar(); + } + + /** + * Is the response a redirect? + * + * @return boolean True if redirect (3xx status), false if not. + */ + public function is_redirect() { + $code = $this->status_code; + return in_array($code, [300, 301, 302, 303, 307], true) || $code > 307 && $code < 400; + } + + /** + * Throws an exception if the request was not successful + * + * @param boolean $allow_redirects Set to false to throw on a 3xx as well + * + * @throws \WpOrg\Requests\Exception If `$allow_redirects` is false, and code is 3xx (`response.no_redirects`) + * @throws \WpOrg\Requests\Exception\Http On non-successful status code. Exception class corresponds to "Status" + code (e.g. {@see \WpOrg\Requests\Exception\Http\Status404}) + */ + public function throw_for_status($allow_redirects = true) { + if ($this->is_redirect()) { + if ($allow_redirects !== true) { + throw new Exception('Redirection not allowed', 'response.no_redirects', $this); + } + } elseif (!$this->success) { + $exception = Http::get_class($this->status_code); + throw new $exception(null, $this); + } + } + + /** + * JSON decode the response body. + * + * The method parameters are the same as those for the PHP native `json_decode()` function. + * + * @link https://php.net/json-decode + * + * @param bool|null $associative Optional. When `true`, JSON objects will be returned as associative arrays; + * When `false`, JSON objects will be returned as objects. + * When `null`, JSON objects will be returned as associative arrays + * or objects depending on whether `JSON_OBJECT_AS_ARRAY` is set in the flags. + * Defaults to `true` (in contrast to the PHP native default of `null`). + * @param int $depth Optional. Maximum nesting depth of the structure being decoded. + * Defaults to `512`. + * @param int $options Optional. Bitmask of JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_IGNORE, + * JSON_INVALID_UTF8_SUBSTITUTE, JSON_OBJECT_AS_ARRAY, JSON_THROW_ON_ERROR. + * Defaults to `0` (no options set). + * + * @return array + * + * @throws \WpOrg\Requests\Exception If `$this->body` is not valid json. + */ + public function decode_body($associative = true, $depth = 512, $options = 0) { + $data = json_decode($this->body, $associative, $depth, $options); + + if (json_last_error() !== JSON_ERROR_NONE) { + $last_error = json_last_error_msg(); + throw new Exception('Unable to parse JSON data: ' . $last_error, 'response.invalid', $this); + } + + return $data; + } +} diff --git a/wp-includes/Requests/src/Response/Headers.php b/wp-includes/Requests/src/Response/Headers.php new file mode 100644 index 0000000..b4d0fcf --- /dev/null +++ b/wp-includes/Requests/src/Response/Headers.php @@ -0,0 +1,127 @@ +data[$offset])) { + return null; + } + + return $this->flatten($this->data[$offset]); + } + + /** + * Set the given item + * + * @param string $offset Item name + * @param string $value Item value + * + * @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`) + */ + public function offsetSet($offset, $value) { + if ($offset === null) { + throw new Exception('Object is a dictionary, not a list', 'invalidset'); + } + + if (is_string($offset)) { + $offset = strtolower($offset); + } + + if (!isset($this->data[$offset])) { + $this->data[$offset] = []; + } + + $this->data[$offset][] = $value; + } + + /** + * Get all values for a given header + * + * @param string $offset Name of the header to retrieve. + * @return array|null Header values + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not valid as an array key. + */ + public function getValues($offset) { + if (!is_string($offset) && !is_int($offset)) { + throw InvalidArgument::create(1, '$offset', 'string|int', gettype($offset)); + } + + if (is_string($offset)) { + $offset = strtolower($offset); + } + + if (!isset($this->data[$offset])) { + return null; + } + + return $this->data[$offset]; + } + + /** + * Flattens a value into a string + * + * Converts an array into a string by imploding values with a comma, as per + * RFC2616's rules for folding headers. + * + * @param string|array $value Value to flatten + * @return string Flattened value + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or an array. + */ + public function flatten($value) { + if (is_string($value)) { + return $value; + } + + if (is_array($value)) { + return implode(',', $value); + } + + throw InvalidArgument::create(1, '$value', 'string|array', gettype($value)); + } + + /** + * Get an iterator for the data + * + * Converts the internally stored values to a comma-separated string if there is more + * than one value for a key. + * + * @return \ArrayIterator + */ + public function getIterator() { + return new FilteredIterator($this->data, [$this, 'flatten']); + } +} diff --git a/wp-includes/Requests/src/Session.php b/wp-includes/Requests/src/Session.php new file mode 100644 index 0000000..0a63279 --- /dev/null +++ b/wp-includes/Requests/src/Session.php @@ -0,0 +1,308 @@ +useragent = 'X';` + * + * @var array + */ + public $options = []; + + /** + * Create a new session + * + * @param string|Stringable|null $url Base URL for requests + * @param array $headers Default headers for requests + * @param array $data Default data for requests + * @param array $options Default options for requests + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string, Stringable or null. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data argument is not an array. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + */ + public function __construct($url = null, $headers = [], $data = [], $options = []) { + if ($url !== null && InputValidator::is_string_or_stringable($url) === false) { + throw InvalidArgument::create(1, '$url', 'string|Stringable|null', gettype($url)); + } + + if (is_array($headers) === false) { + throw InvalidArgument::create(2, '$headers', 'array', gettype($headers)); + } + + if (is_array($data) === false) { + throw InvalidArgument::create(3, '$data', 'array', gettype($data)); + } + + if (is_array($options) === false) { + throw InvalidArgument::create(4, '$options', 'array', gettype($options)); + } + + $this->url = $url; + $this->headers = $headers; + $this->data = $data; + $this->options = $options; + + if (empty($this->options['cookies'])) { + $this->options['cookies'] = new Jar(); + } + } + + /** + * Get a property's value + * + * @param string $name Property name. + * @return mixed|null Property value, null if none found + */ + public function __get($name) { + if (isset($this->options[$name])) { + return $this->options[$name]; + } + + return null; + } + + /** + * Set a property's value + * + * @param string $name Property name. + * @param mixed $value Property value + */ + public function __set($name, $value) { + $this->options[$name] = $value; + } + + /** + * Remove a property's value + * + * @param string $name Property name. + */ + public function __isset($name) { + return isset($this->options[$name]); + } + + /** + * Remove a property's value + * + * @param string $name Property name. + */ + public function __unset($name) { + unset($this->options[$name]); + } + + /**#@+ + * @see \WpOrg\Requests\Session::request() + * @param string $url + * @param array $headers + * @param array $options + * @return \WpOrg\Requests\Response + */ + /** + * Send a GET request + */ + public function get($url, $headers = [], $options = []) { + return $this->request($url, $headers, null, Requests::GET, $options); + } + + /** + * Send a HEAD request + */ + public function head($url, $headers = [], $options = []) { + return $this->request($url, $headers, null, Requests::HEAD, $options); + } + + /** + * Send a DELETE request + */ + public function delete($url, $headers = [], $options = []) { + return $this->request($url, $headers, null, Requests::DELETE, $options); + } + /**#@-*/ + + /**#@+ + * @see \WpOrg\Requests\Session::request() + * @param string $url + * @param array $headers + * @param array $data + * @param array $options + * @return \WpOrg\Requests\Response + */ + /** + * Send a POST request + */ + public function post($url, $headers = [], $data = [], $options = []) { + return $this->request($url, $headers, $data, Requests::POST, $options); + } + + /** + * Send a PUT request + */ + public function put($url, $headers = [], $data = [], $options = []) { + return $this->request($url, $headers, $data, Requests::PUT, $options); + } + + /** + * Send a PATCH request + * + * Note: Unlike {@see \WpOrg\Requests\Session::post()} and {@see \WpOrg\Requests\Session::put()}, + * `$headers` is required, as the specification recommends that should send an ETag + * + * @link https://tools.ietf.org/html/rfc5789 + */ + public function patch($url, $headers, $data = [], $options = []) { + return $this->request($url, $headers, $data, Requests::PATCH, $options); + } + /**#@-*/ + + /** + * Main interface for HTTP requests + * + * This method initiates a request and sends it via a transport before + * parsing. + * + * @see \WpOrg\Requests\Requests::request() + * + * @param string $url URL to request + * @param array $headers Extra headers to send with the request + * @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests + * @param string $type HTTP request type (use \WpOrg\Requests\Requests constants) + * @param array $options Options for the request (see {@see \WpOrg\Requests\Requests::request()}) + * @return \WpOrg\Requests\Response + * + * @throws \WpOrg\Requests\Exception On invalid URLs (`nonhttp`) + */ + public function request($url, $headers = [], $data = [], $type = Requests::GET, $options = []) { + $request = $this->merge_request(compact('url', 'headers', 'data', 'options')); + + return Requests::request($request['url'], $request['headers'], $request['data'], $type, $request['options']); + } + + /** + * Send multiple HTTP requests simultaneously + * + * @see \WpOrg\Requests\Requests::request_multiple() + * + * @param array $requests Requests data (see {@see \WpOrg\Requests\Requests::request_multiple()}) + * @param array $options Global and default options (see {@see \WpOrg\Requests\Requests::request()}) + * @return array Responses (either \WpOrg\Requests\Response or a \WpOrg\Requests\Exception object) + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + */ + public function request_multiple($requests, $options = []) { + if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { + throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); + } + + if (is_array($options) === false) { + throw InvalidArgument::create(2, '$options', 'array', gettype($options)); + } + + foreach ($requests as $key => $request) { + $requests[$key] = $this->merge_request($request, false); + } + + $options = array_merge($this->options, $options); + + // Disallow forcing the type, as that's a per request setting + unset($options['type']); + + return Requests::request_multiple($requests, $options); + } + + public function __wakeup() { + throw new \LogicException( __CLASS__ . ' should never be unserialized' ); + } + + /** + * Merge a request's data with the default data + * + * @param array $request Request data (same form as {@see \WpOrg\Requests\Session::request_multiple()}) + * @param boolean $merge_options Should we merge options as well? + * @return array Request data + */ + protected function merge_request($request, $merge_options = true) { + if ($this->url !== null) { + $request['url'] = Iri::absolutize($this->url, $request['url']); + $request['url'] = $request['url']->uri; + } + + if (empty($request['headers'])) { + $request['headers'] = []; + } + + $request['headers'] = array_merge($this->headers, $request['headers']); + + if (empty($request['data'])) { + if (is_array($this->data)) { + $request['data'] = $this->data; + } + } elseif (is_array($request['data']) && is_array($this->data)) { + $request['data'] = array_merge($this->data, $request['data']); + } + + if ($merge_options === true) { + $request['options'] = array_merge($this->options, $request['options']); + + // Disallow forcing the type, as that's a per request setting + unset($request['options']['type']); + } + + return $request; + } +} diff --git a/wp-includes/Requests/src/Ssl.php b/wp-includes/Requests/src/Ssl.php new file mode 100644 index 0000000..99da11d --- /dev/null +++ b/wp-includes/Requests/src/Ssl.php @@ -0,0 +1,182 @@ + 0) { + // Whitespace detected. This can never be a dNSName. + return false; + } + + $parts = explode('.', $reference); + if ($parts !== array_filter($parts)) { + // DNSName cannot contain two dots next to each other. + return false; + } + + // Check the first part of the name + $first = array_shift($parts); + + if (strpos($first, '*') !== false) { + // Check that the wildcard is the full part + if ($first !== '*') { + return false; + } + + // Check that we have at least 3 components (including first) + if (count($parts) < 2) { + return false; + } + } + + // Check the remaining parts + foreach ($parts as $part) { + if (strpos($part, '*') !== false) { + return false; + } + } + + // Nothing found, verified! + return true; + } + + /** + * Match a hostname against a dNSName reference + * + * @param string|Stringable $host Requested host + * @param string|Stringable $reference dNSName to match against + * @return boolean Does the domain match? + * @throws \WpOrg\Requests\Exception\InvalidArgument When either of the passed arguments is not a string or a stringable object. + */ + public static function match_domain($host, $reference) { + if (InputValidator::is_string_or_stringable($host) === false) { + throw InvalidArgument::create(1, '$host', 'string|Stringable', gettype($host)); + } + + // Check if the reference is blocklisted first + if (self::verify_reference_name($reference) !== true) { + return false; + } + + // Check for a direct match + if ((string) $host === (string) $reference) { + return true; + } + + // Calculate the valid wildcard match if the host is not an IP address + // Also validates that the host has 3 parts or more, as per Firefox's ruleset, + // as a wildcard reference is only allowed with 3 parts or more, so the + // comparison will never match if host doesn't contain 3 parts or more as well. + if (ip2long($host) === false) { + $parts = explode('.', $host); + $parts[0] = '*'; + $wildcard = implode('.', $parts); + if ($wildcard === (string) $reference) { + return true; + } + } + + return false; + } +} diff --git a/wp-includes/Requests/src/Transport.php b/wp-includes/Requests/src/Transport.php new file mode 100644 index 0000000..f2e1c6e --- /dev/null +++ b/wp-includes/Requests/src/Transport.php @@ -0,0 +1,45 @@ + $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. + * @return bool Whether the transport can be used. + */ + public static function test($capabilities = []); +} diff --git a/wp-includes/Requests/src/Transport/Curl.php b/wp-includes/Requests/src/Transport/Curl.php new file mode 100644 index 0000000..7316987 --- /dev/null +++ b/wp-includes/Requests/src/Transport/Curl.php @@ -0,0 +1,640 @@ += 8.0. + */ + private $handle; + + /** + * Hook dispatcher instance + * + * @var \WpOrg\Requests\Hooks + */ + private $hooks; + + /** + * Have we finished the headers yet? + * + * @var boolean + */ + private $done_headers = false; + + /** + * If streaming to a file, keep the file pointer + * + * @var resource + */ + private $stream_handle; + + /** + * How many bytes are in the response body? + * + * @var int + */ + private $response_bytes; + + /** + * What's the maximum number of bytes we should keep? + * + * @var int|bool Byte count, or false if no limit. + */ + private $response_byte_limit; + + /** + * Constructor + */ + public function __construct() { + $curl = curl_version(); + $this->version = $curl['version_number']; + $this->handle = curl_init(); + + curl_setopt($this->handle, CURLOPT_HEADER, false); + curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, 1); + if ($this->version >= self::CURL_7_10_5) { + curl_setopt($this->handle, CURLOPT_ENCODING, ''); + } + + if (defined('CURLOPT_PROTOCOLS')) { + // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_protocolsFound + curl_setopt($this->handle, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + } + + if (defined('CURLOPT_REDIR_PROTOCOLS')) { + // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_redir_protocolsFound + curl_setopt($this->handle, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + } + } + + /** + * Destructor + */ + public function __destruct() { + if (is_resource($this->handle)) { + curl_close($this->handle); + } + } + + /** + * Perform a request + * + * @param string|Stringable $url URL to request + * @param array $headers Associative array of request headers + * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD + * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation + * @return string Raw HTTP result + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data parameter is not an array or string. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + * @throws \WpOrg\Requests\Exception On a cURL error (`curlerror`) + */ + public function request($url, $headers = [], $data = [], $options = []) { + if (InputValidator::is_string_or_stringable($url) === false) { + throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url)); + } + + if (is_array($headers) === false) { + throw InvalidArgument::create(2, '$headers', 'array', gettype($headers)); + } + + if (!is_array($data) && !is_string($data)) { + if ($data === null) { + $data = ''; + } else { + throw InvalidArgument::create(3, '$data', 'array|string', gettype($data)); + } + } + + if (is_array($options) === false) { + throw InvalidArgument::create(4, '$options', 'array', gettype($options)); + } + + $this->hooks = $options['hooks']; + + $this->setup_handle($url, $headers, $data, $options); + + $options['hooks']->dispatch('curl.before_send', [&$this->handle]); + + if ($options['filename'] !== false) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception. + $this->stream_handle = @fopen($options['filename'], 'wb'); + if ($this->stream_handle === false) { + $error = error_get_last(); + throw new Exception($error['message'], 'fopen'); + } + } + + $this->response_data = ''; + $this->response_bytes = 0; + $this->response_byte_limit = false; + if ($options['max_bytes'] !== false) { + $this->response_byte_limit = $options['max_bytes']; + } + + if (isset($options['verify'])) { + if ($options['verify'] === false) { + curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($this->handle, CURLOPT_SSL_VERIFYPEER, 0); + } elseif (is_string($options['verify'])) { + curl_setopt($this->handle, CURLOPT_CAINFO, $options['verify']); + } + } + + if (isset($options['verifyname']) && $options['verifyname'] === false) { + curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST, 0); + } + + curl_exec($this->handle); + $response = $this->response_data; + + $options['hooks']->dispatch('curl.after_send', []); + + if (curl_errno($this->handle) === CURLE_WRITE_ERROR || curl_errno($this->handle) === CURLE_BAD_CONTENT_ENCODING) { + // Reset encoding and try again + curl_setopt($this->handle, CURLOPT_ENCODING, 'none'); + + $this->response_data = ''; + $this->response_bytes = 0; + curl_exec($this->handle); + $response = $this->response_data; + } + + $this->process_response($response, $options); + + // Need to remove the $this reference from the curl handle. + // Otherwise \WpOrg\Requests\Transport\Curl won't be garbage collected and the curl_close() will never be called. + curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, null); + curl_setopt($this->handle, CURLOPT_WRITEFUNCTION, null); + + return $this->headers; + } + + /** + * Send multiple requests simultaneously + * + * @param array $requests Request data + * @param array $options Global options + * @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well) + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + */ + public function request_multiple($requests, $options) { + // If you're not requesting, we can't get any responses ¯\_(ツ)_/¯ + if (empty($requests)) { + return []; + } + + if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { + throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); + } + + if (is_array($options) === false) { + throw InvalidArgument::create(2, '$options', 'array', gettype($options)); + } + + $multihandle = curl_multi_init(); + $subrequests = []; + $subhandles = []; + + $class = get_class($this); + foreach ($requests as $id => $request) { + $subrequests[$id] = new $class(); + $subhandles[$id] = $subrequests[$id]->get_subrequest_handle($request['url'], $request['headers'], $request['data'], $request['options']); + $request['options']['hooks']->dispatch('curl.before_multi_add', [&$subhandles[$id]]); + curl_multi_add_handle($multihandle, $subhandles[$id]); + } + + $completed = 0; + $responses = []; + $subrequestcount = count($subrequests); + + $request['options']['hooks']->dispatch('curl.before_multi_exec', [&$multihandle]); + + do { + $active = 0; + + do { + $status = curl_multi_exec($multihandle, $active); + } while ($status === CURLM_CALL_MULTI_PERFORM); + + $to_process = []; + + // Read the information as needed + while ($done = curl_multi_info_read($multihandle)) { + $key = array_search($done['handle'], $subhandles, true); + if (!isset($to_process[$key])) { + $to_process[$key] = $done; + } + } + + // Parse the finished requests before we start getting the new ones + foreach ($to_process as $key => $done) { + $options = $requests[$key]['options']; + if ($done['result'] !== CURLE_OK) { + //get error string for handle. + $reason = curl_error($done['handle']); + $exception = new CurlException( + $reason, + CurlException::EASY, + $done['handle'], + $done['result'] + ); + $responses[$key] = $exception; + $options['hooks']->dispatch('transport.internal.parse_error', [&$responses[$key], $requests[$key]]); + } else { + $responses[$key] = $subrequests[$key]->process_response($subrequests[$key]->response_data, $options); + + $options['hooks']->dispatch('transport.internal.parse_response', [&$responses[$key], $requests[$key]]); + } + + curl_multi_remove_handle($multihandle, $done['handle']); + curl_close($done['handle']); + + if (!is_string($responses[$key])) { + $options['hooks']->dispatch('multiple.request.complete', [&$responses[$key], $key]); + } + + $completed++; + } + } while ($active || $completed < $subrequestcount); + + $request['options']['hooks']->dispatch('curl.after_multi_exec', [&$multihandle]); + + curl_multi_close($multihandle); + + return $responses; + } + + /** + * Get the cURL handle for use in a multi-request + * + * @param string $url URL to request + * @param array $headers Associative array of request headers + * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD + * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation + * @return resource|\CurlHandle Subrequest's cURL handle + */ + public function &get_subrequest_handle($url, $headers, $data, $options) { + $this->setup_handle($url, $headers, $data, $options); + + if ($options['filename'] !== false) { + $this->stream_handle = fopen($options['filename'], 'wb'); + } + + $this->response_data = ''; + $this->response_bytes = 0; + $this->response_byte_limit = false; + if ($options['max_bytes'] !== false) { + $this->response_byte_limit = $options['max_bytes']; + } + + $this->hooks = $options['hooks']; + + return $this->handle; + } + + /** + * Setup the cURL handle for the given data + * + * @param string $url URL to request + * @param array $headers Associative array of request headers + * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD + * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation + */ + private function setup_handle($url, $headers, $data, $options) { + $options['hooks']->dispatch('curl.before_request', [&$this->handle]); + + // Force closing the connection for old versions of cURL (<7.22). + if (!isset($headers['Connection'])) { + $headers['Connection'] = 'close'; + } + + /** + * Add "Expect" header. + * + * By default, cURL adds a "Expect: 100-Continue" to most requests. This header can + * add as much as a second to the time it takes for cURL to perform a request. To + * prevent this, we need to set an empty "Expect" header. To match the behaviour of + * Guzzle, we'll add the empty header to requests that are smaller than 1 MB and use + * HTTP/1.1. + * + * https://curl.se/mail/lib-2017-07/0013.html + */ + if (!isset($headers['Expect']) && $options['protocol_version'] === 1.1) { + $headers['Expect'] = $this->get_expect_header($data); + } + + $headers = Requests::flatten($headers); + + if (!empty($data)) { + $data_format = $options['data_format']; + + if ($data_format === 'query') { + $url = self::format_get($url, $data); + $data = ''; + } elseif (!is_string($data)) { + $data = http_build_query($data, '', '&'); + } + } + + switch ($options['type']) { + case Requests::POST: + curl_setopt($this->handle, CURLOPT_POST, true); + curl_setopt($this->handle, CURLOPT_POSTFIELDS, $data); + break; + case Requests::HEAD: + curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']); + curl_setopt($this->handle, CURLOPT_NOBODY, true); + break; + case Requests::TRACE: + curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']); + break; + case Requests::PATCH: + case Requests::PUT: + case Requests::DELETE: + case Requests::OPTIONS: + default: + curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']); + if (!empty($data)) { + curl_setopt($this->handle, CURLOPT_POSTFIELDS, $data); + } + } + + // cURL requires a minimum timeout of 1 second when using the system + // DNS resolver, as it uses `alarm()`, which is second resolution only. + // There's no way to detect which DNS resolver is being used from our + // end, so we need to round up regardless of the supplied timeout. + // + // https://github.com/curl/curl/blob/4f45240bc84a9aa648c8f7243be7b79e9f9323a5/lib/hostip.c#L606-L609 + $timeout = max($options['timeout'], 1); + + if (is_int($timeout) || $this->version < self::CURL_7_16_2) { + curl_setopt($this->handle, CURLOPT_TIMEOUT, ceil($timeout)); + } else { + // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_timeout_msFound + curl_setopt($this->handle, CURLOPT_TIMEOUT_MS, round($timeout * 1000)); + } + + if (is_int($options['connect_timeout']) || $this->version < self::CURL_7_16_2) { + curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT, ceil($options['connect_timeout'])); + } else { + // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttimeout_msFound + curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000)); + } + + curl_setopt($this->handle, CURLOPT_URL, $url); + curl_setopt($this->handle, CURLOPT_USERAGENT, $options['useragent']); + if (!empty($headers)) { + curl_setopt($this->handle, CURLOPT_HTTPHEADER, $headers); + } + + if ($options['protocol_version'] === 1.1) { + curl_setopt($this->handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + } else { + curl_setopt($this->handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0); + } + + if ($options['blocking'] === true) { + curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, [$this, 'stream_headers']); + curl_setopt($this->handle, CURLOPT_WRITEFUNCTION, [$this, 'stream_body']); + curl_setopt($this->handle, CURLOPT_BUFFERSIZE, Requests::BUFFER_SIZE); + } + } + + /** + * Process a response + * + * @param string $response Response data from the body + * @param array $options Request options + * @return string|false HTTP response data including headers. False if non-blocking. + * @throws \WpOrg\Requests\Exception If the request resulted in a cURL error. + */ + public function process_response($response, $options) { + if ($options['blocking'] === false) { + $fake_headers = ''; + $options['hooks']->dispatch('curl.after_request', [&$fake_headers]); + return false; + } + + if ($options['filename'] !== false && $this->stream_handle) { + fclose($this->stream_handle); + $this->headers = trim($this->headers); + } else { + $this->headers .= $response; + } + + if (curl_errno($this->handle)) { + $error = sprintf( + 'cURL error %s: %s', + curl_errno($this->handle), + curl_error($this->handle) + ); + throw new Exception($error, 'curlerror', $this->handle); + } + + $this->info = curl_getinfo($this->handle); + + $options['hooks']->dispatch('curl.after_request', [&$this->headers, &$this->info]); + return $this->headers; + } + + /** + * Collect the headers as they are received + * + * @param resource|\CurlHandle $handle cURL handle + * @param string $headers Header string + * @return integer Length of provided header + */ + public function stream_headers($handle, $headers) { + // Why do we do this? cURL will send both the final response and any + // interim responses, such as a 100 Continue. We don't need that. + // (We may want to keep this somewhere just in case) + if ($this->done_headers) { + $this->headers = ''; + $this->done_headers = false; + } + + $this->headers .= $headers; + + if ($headers === "\r\n") { + $this->done_headers = true; + } + + return strlen($headers); + } + + /** + * Collect data as it's received + * + * @since 1.6.1 + * + * @param resource|\CurlHandle $handle cURL handle + * @param string $data Body data + * @return integer Length of provided data + */ + public function stream_body($handle, $data) { + $this->hooks->dispatch('request.progress', [$data, $this->response_bytes, $this->response_byte_limit]); + $data_length = strlen($data); + + // Are we limiting the response size? + if ($this->response_byte_limit) { + if ($this->response_bytes === $this->response_byte_limit) { + // Already at maximum, move on + return $data_length; + } + + if (($this->response_bytes + $data_length) > $this->response_byte_limit) { + // Limit the length + $limited_length = ($this->response_byte_limit - $this->response_bytes); + $data = substr($data, 0, $limited_length); + } + } + + if ($this->stream_handle) { + fwrite($this->stream_handle, $data); + } else { + $this->response_data .= $data; + } + + $this->response_bytes += strlen($data); + return $data_length; + } + + /** + * Format a URL given GET data + * + * @param string $url Original URL. + * @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query} + * @return string URL with data + */ + private static function format_get($url, $data) { + if (!empty($data)) { + $query = ''; + $url_parts = parse_url($url); + if (empty($url_parts['query'])) { + $url_parts['query'] = ''; + } else { + $query = $url_parts['query']; + } + + $query .= '&' . http_build_query($data, '', '&'); + $query = trim($query, '&'); + + if (empty($url_parts['query'])) { + $url .= '?' . $query; + } else { + $url = str_replace($url_parts['query'], $query, $url); + } + } + + return $url; + } + + /** + * Self-test whether the transport can be used. + * + * The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}. + * + * @codeCoverageIgnore + * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. + * @return bool Whether the transport can be used. + */ + public static function test($capabilities = []) { + if (!function_exists('curl_init') || !function_exists('curl_exec')) { + return false; + } + + // If needed, check that our installed curl version supports SSL + if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) { + $curl_version = curl_version(); + if (!(CURL_VERSION_SSL & $curl_version['features'])) { + return false; + } + } + + return true; + } + + /** + * Get the correct "Expect" header for the given request data. + * + * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD. + * @return string The "Expect" header. + */ + private function get_expect_header($data) { + if (!is_array($data)) { + return strlen((string) $data) >= 1048576 ? '100-Continue' : ''; + } + + $bytesize = 0; + $iterator = new RecursiveIteratorIterator(new RecursiveArrayIterator($data)); + + foreach ($iterator as $datum) { + $bytesize += strlen((string) $datum); + + if ($bytesize >= 1048576) { + return '100-Continue'; + } + } + + return ''; + } +} diff --git a/wp-includes/Requests/src/Transport/Fsockopen.php b/wp-includes/Requests/src/Transport/Fsockopen.php new file mode 100644 index 0000000..2b53d0c --- /dev/null +++ b/wp-includes/Requests/src/Transport/Fsockopen.php @@ -0,0 +1,510 @@ +dispatch('fsockopen.before_request'); + + $url_parts = parse_url($url); + if (empty($url_parts)) { + throw new Exception('Invalid URL.', 'invalidurl', $url); + } + + $host = $url_parts['host']; + $context = stream_context_create(); + $verifyname = false; + $case_insensitive_headers = new CaseInsensitiveDictionary($headers); + + // HTTPS support + if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') { + $remote_socket = 'ssl://' . $host; + if (!isset($url_parts['port'])) { + $url_parts['port'] = Port::HTTPS; + } + + $context_options = [ + 'verify_peer' => true, + 'capture_peer_cert' => true, + ]; + $verifyname = true; + + // SNI, if enabled (OpenSSL >=0.9.8j) + // phpcs:ignore PHPCompatibility.Constants.NewConstants.openssl_tlsext_server_nameFound + if (defined('OPENSSL_TLSEXT_SERVER_NAME') && OPENSSL_TLSEXT_SERVER_NAME) { + $context_options['SNI_enabled'] = true; + if (isset($options['verifyname']) && $options['verifyname'] === false) { + $context_options['SNI_enabled'] = false; + } + } + + if (isset($options['verify'])) { + if ($options['verify'] === false) { + $context_options['verify_peer'] = false; + $context_options['verify_peer_name'] = false; + $verifyname = false; + } elseif (is_string($options['verify'])) { + $context_options['cafile'] = $options['verify']; + } + } + + if (isset($options['verifyname']) && $options['verifyname'] === false) { + $context_options['verify_peer_name'] = false; + $verifyname = false; + } + + stream_context_set_option($context, ['ssl' => $context_options]); + } else { + $remote_socket = 'tcp://' . $host; + } + + $this->max_bytes = $options['max_bytes']; + + if (!isset($url_parts['port'])) { + $url_parts['port'] = Port::HTTP; + } + + $remote_socket .= ':' . $url_parts['port']; + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler + set_error_handler([$this, 'connect_error_handler'], E_WARNING | E_NOTICE); + + $options['hooks']->dispatch('fsockopen.remote_socket', [&$remote_socket]); + + $socket = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context); + + restore_error_handler(); + + if ($verifyname && !$this->verify_certificate_from_context($host, $context)) { + throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match'); + } + + if (!$socket) { + if ($errno === 0) { + // Connection issue + throw new Exception(rtrim($this->connect_error), 'fsockopen.connect_error'); + } + + throw new Exception($errstr, 'fsockopenerror', null, $errno); + } + + $data_format = $options['data_format']; + + if ($data_format === 'query') { + $path = self::format_get($url_parts, $data); + $data = ''; + } else { + $path = self::format_get($url_parts, []); + } + + $options['hooks']->dispatch('fsockopen.remote_host_path', [&$path, $url]); + + $request_body = ''; + $out = sprintf("%s %s HTTP/%.1F\r\n", $options['type'], $path, $options['protocol_version']); + + if ($options['type'] !== Requests::TRACE) { + if (is_array($data)) { + $request_body = http_build_query($data, '', '&'); + } else { + $request_body = $data; + } + + // Always include Content-length on POST requests to prevent + // 411 errors from some servers when the body is empty. + if (!empty($data) || $options['type'] === Requests::POST) { + if (!isset($case_insensitive_headers['Content-Length'])) { + $headers['Content-Length'] = strlen($request_body); + } + + if (!isset($case_insensitive_headers['Content-Type'])) { + $headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; + } + } + } + + if (!isset($case_insensitive_headers['Host'])) { + $out .= sprintf('Host: %s', $url_parts['host']); + $scheme_lower = strtolower($url_parts['scheme']); + + if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) { + $out .= ':' . $url_parts['port']; + } + + $out .= "\r\n"; + } + + if (!isset($case_insensitive_headers['User-Agent'])) { + $out .= sprintf("User-Agent: %s\r\n", $options['useragent']); + } + + $accept_encoding = $this->accept_encoding(); + if (!isset($case_insensitive_headers['Accept-Encoding']) && !empty($accept_encoding)) { + $out .= sprintf("Accept-Encoding: %s\r\n", $accept_encoding); + } + + $headers = Requests::flatten($headers); + + if (!empty($headers)) { + $out .= implode("\r\n", $headers) . "\r\n"; + } + + $options['hooks']->dispatch('fsockopen.after_headers', [&$out]); + + if (substr($out, -2) !== "\r\n") { + $out .= "\r\n"; + } + + if (!isset($case_insensitive_headers['Connection'])) { + $out .= "Connection: Close\r\n"; + } + + $out .= "\r\n" . $request_body; + + $options['hooks']->dispatch('fsockopen.before_send', [&$out]); + + fwrite($socket, $out); + $options['hooks']->dispatch('fsockopen.after_send', [$out]); + + if (!$options['blocking']) { + fclose($socket); + $fake_headers = ''; + $options['hooks']->dispatch('fsockopen.after_request', [&$fake_headers]); + return ''; + } + + $timeout_sec = (int) floor($options['timeout']); + if ($timeout_sec === $options['timeout']) { + $timeout_msec = 0; + } else { + $timeout_msec = self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS; + } + + stream_set_timeout($socket, $timeout_sec, $timeout_msec); + + $response = ''; + $body = ''; + $headers = ''; + $this->info = stream_get_meta_data($socket); + $size = 0; + $doingbody = false; + $download = false; + if ($options['filename']) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception. + $download = @fopen($options['filename'], 'wb'); + if ($download === false) { + $error = error_get_last(); + throw new Exception($error['message'], 'fopen'); + } + } + + while (!feof($socket)) { + $this->info = stream_get_meta_data($socket); + if ($this->info['timed_out']) { + throw new Exception('fsocket timed out', 'timeout'); + } + + $block = fread($socket, Requests::BUFFER_SIZE); + if (!$doingbody) { + $response .= $block; + if (strpos($response, "\r\n\r\n")) { + list($headers, $block) = explode("\r\n\r\n", $response, 2); + $doingbody = true; + } + } + + // Are we in body mode now? + if ($doingbody) { + $options['hooks']->dispatch('request.progress', [$block, $size, $this->max_bytes]); + $data_length = strlen($block); + if ($this->max_bytes) { + // Have we already hit a limit? + if ($size === $this->max_bytes) { + continue; + } + + if (($size + $data_length) > $this->max_bytes) { + // Limit the length + $limited_length = ($this->max_bytes - $size); + $block = substr($block, 0, $limited_length); + } + } + + $size += strlen($block); + if ($download) { + fwrite($download, $block); + } else { + $body .= $block; + } + } + } + + $this->headers = $headers; + + if ($download) { + fclose($download); + } else { + $this->headers .= "\r\n\r\n" . $body; + } + + fclose($socket); + + $options['hooks']->dispatch('fsockopen.after_request', [&$this->headers, &$this->info]); + return $this->headers; + } + + /** + * Send multiple requests simultaneously + * + * @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see \WpOrg\Requests\Transport::request()} + * @param array $options Global options, see {@see \WpOrg\Requests\Requests::response()} for documentation + * @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well) + * + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. + * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. + */ + public function request_multiple($requests, $options) { + // If you're not requesting, we can't get any responses ¯\_(ツ)_/¯ + if (empty($requests)) { + return []; + } + + if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { + throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); + } + + if (is_array($options) === false) { + throw InvalidArgument::create(2, '$options', 'array', gettype($options)); + } + + $responses = []; + $class = get_class($this); + foreach ($requests as $id => $request) { + try { + $handler = new $class(); + $responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']); + + $request['options']['hooks']->dispatch('transport.internal.parse_response', [&$responses[$id], $request]); + } catch (Exception $e) { + $responses[$id] = $e; + } + + if (!is_string($responses[$id])) { + $request['options']['hooks']->dispatch('multiple.request.complete', [&$responses[$id], $id]); + } + } + + return $responses; + } + + /** + * Retrieve the encodings we can accept + * + * @return string Accept-Encoding header value + */ + private static function accept_encoding() { + $type = []; + if (function_exists('gzinflate')) { + $type[] = 'deflate;q=1.0'; + } + + if (function_exists('gzuncompress')) { + $type[] = 'compress;q=0.5'; + } + + $type[] = 'gzip;q=0.5'; + + return implode(', ', $type); + } + + /** + * Format a URL given GET data + * + * @param array $url_parts Array of URL parts as received from {@link https://www.php.net/parse_url} + * @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query} + * @return string URL with data + */ + private static function format_get($url_parts, $data) { + if (!empty($data)) { + if (empty($url_parts['query'])) { + $url_parts['query'] = ''; + } + + $url_parts['query'] .= '&' . http_build_query($data, '', '&'); + $url_parts['query'] = trim($url_parts['query'], '&'); + } + + if (isset($url_parts['path'])) { + if (isset($url_parts['query'])) { + $get = $url_parts['path'] . '?' . $url_parts['query']; + } else { + $get = $url_parts['path']; + } + } else { + $get = '/'; + } + + return $get; + } + + /** + * Error handler for stream_socket_client() + * + * @param int $errno Error number (e.g. E_WARNING) + * @param string $errstr Error message + */ + public function connect_error_handler($errno, $errstr) { + // Double-check we can handle it + if (($errno & E_WARNING) === 0 && ($errno & E_NOTICE) === 0) { + // Return false to indicate the default error handler should engage + return false; + } + + $this->connect_error .= $errstr . "\n"; + return true; + } + + /** + * Verify the certificate against common name and subject alternative names + * + * Unfortunately, PHP doesn't check the certificate against the alternative + * names, leading things like 'https://www.github.com/' to be invalid. + * Instead + * + * @link https://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1 + * + * @param string $host Host name to verify against + * @param resource $context Stream context + * @return bool + * + * @throws \WpOrg\Requests\Exception On failure to connect via TLS (`fsockopen.ssl.connect_error`) + * @throws \WpOrg\Requests\Exception On not obtaining a match for the host (`fsockopen.ssl.no_match`) + */ + public function verify_certificate_from_context($host, $context) { + $meta = stream_context_get_options($context); + + // If we don't have SSL options, then we couldn't make the connection at + // all + if (empty($meta) || empty($meta['ssl']) || empty($meta['ssl']['peer_certificate'])) { + throw new Exception(rtrim($this->connect_error), 'ssl.connect_error'); + } + + $cert = openssl_x509_parse($meta['ssl']['peer_certificate']); + + return Ssl::verify_certificate($host, $cert); + } + + /** + * Self-test whether the transport can be used. + * + * The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}. + * + * @codeCoverageIgnore + * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. + * @return bool Whether the transport can be used. + */ + public static function test($capabilities = []) { + if (!function_exists('fsockopen')) { + return false; + } + + // If needed, check that streams support SSL + if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) { + if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) { + return false; + } + } + + return true; + } +} diff --git a/wp-includes/Requests/src/Utility/CaseInsensitiveDictionary.php b/wp-includes/Requests/src/Utility/CaseInsensitiveDictionary.php new file mode 100644 index 0000000..0e1a914 --- /dev/null +++ b/wp-includes/Requests/src/Utility/CaseInsensitiveDictionary.php @@ -0,0 +1,127 @@ + $value) { + $this->offsetSet($offset, $value); + } + } + + /** + * Check if the given item exists + * + * @param string $offset Item key + * @return boolean Does the item exist? + */ + #[ReturnTypeWillChange] + public function offsetExists($offset) { + if (is_string($offset)) { + $offset = strtolower($offset); + } + + return isset($this->data[$offset]); + } + + /** + * Get the value for the item + * + * @param string $offset Item key + * @return string|null Item value (null if the item key doesn't exist) + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) { + if (is_string($offset)) { + $offset = strtolower($offset); + } + + if (!isset($this->data[$offset])) { + return null; + } + + return $this->data[$offset]; + } + + /** + * Set the given item + * + * @param string $offset Item name + * @param string $value Item value + * + * @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`) + */ + #[ReturnTypeWillChange] + public function offsetSet($offset, $value) { + if ($offset === null) { + throw new Exception('Object is a dictionary, not a list', 'invalidset'); + } + + if (is_string($offset)) { + $offset = strtolower($offset); + } + + $this->data[$offset] = $value; + } + + /** + * Unset the given header + * + * @param string $offset The key for the item to unset. + */ + #[ReturnTypeWillChange] + public function offsetUnset($offset) { + if (is_string($offset)) { + $offset = strtolower($offset); + } + + unset($this->data[$offset]); + } + + /** + * Get an iterator for the data + * + * @return \ArrayIterator + */ + #[ReturnTypeWillChange] + public function getIterator() { + return new ArrayIterator($this->data); + } + + /** + * Get the headers as an array + * + * @return array Header data + */ + public function getAll() { + return $this->data; + } +} diff --git a/wp-includes/Requests/src/Utility/FilteredIterator.php b/wp-includes/Requests/src/Utility/FilteredIterator.php new file mode 100644 index 0000000..4865966 --- /dev/null +++ b/wp-includes/Requests/src/Utility/FilteredIterator.php @@ -0,0 +1,97 @@ +callback = $callback; + } + } + + /** + * Prevent unserialization of the object for security reasons. + * + * @phpcs:disable PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__unserializeFound + * + * @param array $data Restored array of data originally serialized. + * + * @return void + */ + #[ReturnTypeWillChange] + public function __unserialize($data) {} + // phpcs:enable + + /** + * Perform reinitialization tasks. + * + * Prevents a callback from being injected during unserialization of an object. + * + * @return void + */ + public function __wakeup() { + unset($this->callback); + } + + /** + * Get the current item's value after filtering + * + * @return string + */ + #[ReturnTypeWillChange] + public function current() { + $value = parent::current(); + + if (is_callable($this->callback)) { + $value = call_user_func($this->callback, $value); + } + + return $value; + } + + /** + * Prevent creating a PHP value from a stored representation of the object for security reasons. + * + * @param string $data The serialized string. + * + * @return void + */ + #[ReturnTypeWillChange] + public function unserialize($data) {} +} diff --git a/wp-includes/Requests/src/Utility/InputValidator.php b/wp-includes/Requests/src/Utility/InputValidator.php new file mode 100644 index 0000000..7c10d61 --- /dev/null +++ b/wp-includes/Requests/src/Utility/InputValidator.php @@ -0,0 +1,109 @@ +