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.
*
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
*
*
* @since 2.1.0
*

* @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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @copyright 2012-2023 Requests Contributors
* @copyright 2012-2026 Requests Contributors

(here and elsewhere - or should we do this codebase wide after this PR has been merged ?)

* @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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @since 2.x.x
* @since 2.1.0

*/
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @return self
* @return \WpOrg\Requests\Exception\MissingIpAddress

*/
public static function for_host($host) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static function for_host($host) {
public static function create($host) {

Reason for this suggestion: in other Exceptions introduced over the past few years, we've used the pattern of using static initializers called create().

$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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @since 2.x.x
* @since 2.1.0

*/
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @return self
* @return \WpOrg\Requests\Exception\UnknownHost

*/
public static function for_host($host) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static function for_host($host) {
public static function create($host) {

Reason for this suggestion: in other Exceptions introduced over the past few years, we've used the pattern of using static initializers called create().

$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
);
Comment on lines +472 to +479
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$need_host_bindings =
array_key_exists(Capability::HOST_BINDINGS, $options)
&&
(
is_array($options[Capability::HOST_BINDINGS])
||
$options[Capability::HOST_BINDINGS] instanceof HostBindings
);
$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
120 changes: 116 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'])) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!isset($case_insensitive_headers['Connection'])) {
if (!isset($case_insensitive_headers['connection'])) {

If you are making it case-insensitive.... ?

$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,103 @@ 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
// CURLOPT_CONNECT_TO (cURL 7.49.0 / PHP 7.0.7) is always defined, so this
// older fallback using CURLOPT_RESOLVE is never reached.
// @codeCoverageIgnoreStart
} 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}"]);
// @codeCoverageIgnoreEnd
// Both CURLOPT_CONNECT_TO and CURLOPT_RESOLVE are always defined, so this
// last-resort HTTP URL rewriting fallback is never reached.
// @TODO: Extract into a utility class to make this testable independently.
// @codeCoverageIgnoreStart
} elseif ($parsed['scheme'] === 'http') {
$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;
}

// @codeCoverageIgnoreEnd
}

// 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 +562,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 +731,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