Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Capability.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
*/
interface Capability {

/**
* Support for mapping specific hosts to specific IP addresses.
*
* @var string
*/
const HOST_BINDINGS = 'host_bindings';

/**
* Support for SSL.
*
Expand All @@ -34,6 +41,7 @@ interface Capability {
* @var array<string>
*/
const ALL = [
self::HOST_BINDINGS,
self::SSL,
];
}
33 changes: 33 additions & 0 deletions src/Exception/MissingIpAddress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
/**
* Requests for PHP, an HTTP library.
*
* @copyright 2012-2023 Requests Contributors
* @license https://github.com/WordPress/Requests/blob/stable/LICENSE ISC
* @link https://github.com/WordPress/Requests
*/

namespace WpOrg\Requests\Exception;

use RangeException;

/**
* Exception for a missing IP address in the host bindings.
*
* @package Requests\Exceptions
* @since 2.x.x
*/
final class MissingIpAddress extends RangeException {

/**
* Instantiate a MissingIpAddress exception for a missing IP address in the host bindings.
*
* @param string $host Host that was requested.
* @return self
*/
public static function for_host($host) {
$message = "No IP address was found for host: {$host}";

return new self($message);
}
}
34 changes: 34 additions & 0 deletions src/Exception/UnknownHost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
/**
* Requests for PHP, an HTTP library.
*
* @copyright 2012-2023 Requests Contributors
* @license https://github.com/WordPress/Requests/blob/stable/LICENSE ISC
* @link https://github.com/WordPress/Requests
*/

namespace WpOrg\Requests\Exception;

use InvalidArgumentException;

/**
* Exception for an unknown host being requested via HostBindings.
*
* @package Requests\Exceptions
* @since 2.x.x
*/
final class UnknownHost extends InvalidArgumentException {

/**
* Instantiate an UnknownHost exception for an unknown host that was
* requested via HostBindings.
*
* @param string $host Unknown host that was requested.
* @return self
*/
public static function for_host($host) {
$message = "Unknown host was requested from the host bindings collection: {$host}";

return new self($message);
}
}
27 changes: 24 additions & 3 deletions src/Requests.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use WpOrg\Requests\Response;
use WpOrg\Requests\Transport\Curl;
use WpOrg\Requests\Transport\Fsockopen;
use WpOrg\Requests\Utility\HostBindings;
use WpOrg\Requests\Utility\InputValidator;

/**
Expand Down Expand Up @@ -417,6 +418,12 @@ public static function patch($url, $headers, $data = [], $options = []) {
* - `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)
* - `host_bindings`: Bind host names to specific IP addresses.
* Accepts either:
* - Array: Keys are domain names, values are arrays of IPv4/IPv6 addresses.
* IP addresses are validated by default.
* - HostBindings object: Pre-constructed for advanced control (e.g., skip validation).
* (array|HostBindings, default: [])
*
* @param string|\Stringable $url URL to request
* @param array $headers Extra headers to send with the request
Expand Down Expand Up @@ -460,9 +467,23 @@ public static function request($url, $headers = [], $data = [], $type = self::GE
$transport = new $transport();
}
} else {
$need_ssl = (stripos($url, 'https://') === 0);
$capabilities = [Capability::SSL => $need_ssl];
$transport = self::get_transport($capabilities);
$need_ssl = (stripos($url, 'https://') === 0);

$need_host_bindings =
array_key_exists(Capability::HOST_BINDINGS, $options)
&&
(
is_array($options[Capability::HOST_BINDINGS])
||
$options[Capability::HOST_BINDINGS] instanceof HostBindings
);

$capabilities = [
Capability::HOST_BINDINGS => $need_host_bindings,
Capability::SSL => $need_ssl,
];

$transport = self::get_transport($capabilities);
}

$response = $transport->request($url, $headers, $data, $options);
Expand Down
111 changes: 107 additions & 4 deletions src/Transport/Curl.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use WpOrg\Requests\Exception\Transport\Curl as CurlException;
use WpOrg\Requests\Requests;
use WpOrg\Requests\Transport;
use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
use WpOrg\Requests\Utility\HostBindings;
use WpOrg\Requests\Utility\InputValidator;

/**
Expand Down Expand Up @@ -385,8 +387,10 @@ public function &get_subrequest_handle($url, $headers, $data, $options) {
private function setup_handle($url, $headers, $data, $options) {
$options['hooks']->dispatch('curl.before_request', [&$this->handle]);

$case_insensitive_headers = new CaseInsensitiveDictionary($headers);

// Force closing the connection for old versions of cURL (<7.22).
if (!isset($headers['Connection'])) {
if (!isset($case_insensitive_headers['Connection'])) {
$headers['Connection'] = 'close';
}

Expand All @@ -405,8 +409,6 @@ private function setup_handle($url, $headers, $data, $options) {
$headers['Expect'] = $this->get_expect_header($data);
}

$headers = Requests::flatten($headers);

if (!empty($data)) {
$data_format = $options['data_format'];

Expand All @@ -418,6 +420,94 @@ private function setup_handle($url, $headers, $data, $options) {
}
}

$exec_url = $url;
$parsed = parse_url($url);
$host = $parsed['host'];

$host_bindings_input = isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : [];

// We allow the application to pass in a pre-constructed HostBindings object
// in case they need to skip IP address validation.
if ($host_bindings_input instanceof HostBindings) {
$host_bindings = $host_bindings_input;
} elseif (is_array($host_bindings_input)) {
$host_bindings = new HostBindings($host_bindings_input);
} elseif (empty($host_bindings_input) === false) {
throw InvalidArgument::create(
4,
'options[host_bindings]',
'array or HostBindings object',
gettype($host_bindings_input)
);
} else {
$host_bindings = new HostBindings([]);
}

if ($host_bindings->has_host($host)) {
if (isset($parsed['port'])) {
$port = $parsed['port'];
$normalized_port = $port;
} else {
$port = '';
$normalized_port = ($parsed['scheme'] === 'http' ? 80 : 443);
}

$exec_ip = $host_bindings->get_first_ip_for_host($host);
// Use square brackets for IPv6 addresses.
$exec_ip = strpos($exec_ip, ':') === false ? $exec_ip : "[{$exec_ip}]";

// @TODO: Extract connect_to/resolve handling into separate method.
if (defined('CURLOPT_CONNECT_TO')) {
$connect_to_string = "{$host}:{$normalized_port}:{$exec_ip}:{$normalized_port}";
// phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttoFound
curl_setopt($this->handle, CURLOPT_CONNECT_TO, [$connect_to_string]); // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connect_toFound
} elseif (defined('CURLOPT_RESOLVE')) {
if (defined('CURLOPT_DNS_USE_GLOBAL_CACHE')) {
// Set to true in PHP's source for most installations.
// Deprecated as of cURL 7.68.0, which removes our need to set this.
curl_setopt($this->handle, CURLOPT_DNS_USE_GLOBAL_CACHE, false);
}

curl_setopt($this->handle, CURLOPT_RESOLVE, ["{$host}:{$normalized_port}:{$exec_ip}"]);
} elseif ($parsed['scheme'] === 'http') {
// @TODO: Use utility class to handle URL assembly.
$exec_url = $parsed['scheme'] . '://';
if (isset($parsed['user'])) {
$exec_url .= $parsed['user'];
if (isset($parsed['pass'])) {
$exec_url .= ':' . $parsed['pass'];
}

$exec_url .= '@';
}

$exec_url .= $exec_ip;
if ($port) {
$exec_url .= ':' . $port;
}

if (isset($parsed['path'])) {
$exec_url .= $parsed['path'];
}

if (isset($parsed['query'])) {
$exec_url .= '?' . $parsed['query'];
}

if (isset($parsed['fragment'])) {
$exec_url .= '#' . $parsed['fragment'];
}

if (!isset($case_insensitive_headers['Host'])) {
$headers['Host'] = $host;
}
}

// Otherwise, there's nothing we can do.
}

$headers = Requests::flatten($headers);

switch ($options['type']) {
case Requests::POST:
curl_setopt($this->handle, CURLOPT_POST, true);
Expand Down Expand Up @@ -463,7 +553,7 @@ private function setup_handle($url, $headers, $data, $options) {
curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000));
}

curl_setopt($this->handle, CURLOPT_URL, $url);
curl_setopt($this->handle, CURLOPT_URL, $exec_url);
curl_setopt($this->handle, CURLOPT_USERAGENT, $options['useragent']);
if (!empty($headers)) {
curl_setopt($this->handle, CURLOPT_HTTPHEADER, $headers);
Expand Down Expand Up @@ -632,6 +722,19 @@ public static function test($capabilities = []) {
return false;
}

// If needed, check that our installed curl version supports host bindings
if (isset($capabilities[Capability::HOST_BINDINGS]) && $capabilities[Capability::HOST_BINDINGS]) {
/*
* CURLOPT_RESOLVE - Added in 7.21.3.
* - Removal support added in 7.42.0.
* - Support for providing multiple IP addresses per entry was added in 7.59.0.
* CURLOPT_CONNECT_TO - Added in 7.49.0.
*/
if (defined('CURLOPT_RESOLVE') === false && defined('CURLOPT_CONNECT_TO') === false) {
return false;
}
}

// If needed, check that our installed curl version supports SSL
if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) {
$curl_version = curl_version();
Expand Down
31 changes: 28 additions & 3 deletions src/Transport/Fsockopen.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use WpOrg\Requests\Ssl;
use WpOrg\Requests\Transport;
use WpOrg\Requests\Utility\CaseInsensitiveDictionary;
use WpOrg\Requests\Utility\HostBindings;
use WpOrg\Requests\Utility\InputValidator;

/**
Expand Down Expand Up @@ -105,13 +106,37 @@ public function request($url, $headers = [], $data = [], $options = []) {
}

$host = $url_parts['host'];
$exec_host = $host;
$context = stream_context_create();
$verifyname = false;
$case_insensitive_headers = new CaseInsensitiveDictionary($headers);

$host_bindings_input = isset($options[Capability::HOST_BINDINGS]) ? $options[Capability::HOST_BINDINGS] : [];

// We allow the application to pass in a pre-constructed HostBindings object
// in case they need to skip IP address validation.
if ($host_bindings_input instanceof HostBindings) {
$host_bindings = $host_bindings_input;
} elseif (is_array($host_bindings_input)) {
$host_bindings = new HostBindings($host_bindings_input);
} elseif (empty($host_bindings_input) === false) {
throw InvalidArgument::create(
4,
'options[host_bindings]',
'array or HostBindings object',
gettype($host_bindings_input)
);
} else {
$host_bindings = new HostBindings([]);
}

if ($host_bindings->has_host($host)) {
$exec_host = $host_bindings->get_first_ip_for_host($host);
}

// HTTPS support
if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') {
$remote_socket = 'ssl://' . $host;
$remote_socket = 'ssl://' . $exec_host;
if (!isset($url_parts['port'])) {
$url_parts['port'] = Port::HTTPS;
}
Expand Down Expand Up @@ -155,7 +180,7 @@ public function request($url, $headers = [], $data = [], $options = []) {
stream_context_set_option($context, ['ssl' => $context_options]);
}
} else {
$remote_socket = 'tcp://' . $host;
$remote_socket = 'tcp://' . $exec_host;
}

$this->max_bytes = $options['max_bytes'];
Expand Down Expand Up @@ -223,7 +248,7 @@ public function request($url, $headers = [], $data = [], $options = []) {
}

if (!isset($case_insensitive_headers['Host'])) {
$out .= sprintf('Host: %s', $url_parts['host']);
$out .= sprintf('Host: %s', $host);
$scheme_lower = strtolower($url_parts['scheme']);

if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) {
Expand Down
Loading
Loading