fix(audit L8, DNS-Rebinding): RemoteFileFetcher nur mit cURL und mit CURLOPT_RESOLVE auf die geprüfte Adresse (Host und Weiterleitungsziele)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 23:55:09 +02:00
co-authored by Claude Sonnet 5.5
parent 06b798ab37
commit 20e02164a5
2 changed files with 149 additions and 7 deletions
+51 -7
View File
@@ -16,8 +16,11 @@ defined('ABSPATH') || exit;
* the font upload. Only http(s), checked with wp_http_validate_url() and the plugin's own range check
* (PublicAddress, independent of the core version) before any request and on every redirect, fetched with
* wp_safe_remote_get() over IPv4 only (core re-validates redirects too), streamed into the temp file and cut off
* one byte above $maxBytes. A failure reaches the caller only as null (no refused/timeout/status oracle, security
* audit M3); the reason goes to the log. Not final so abilities can be tested with a fake.
* one byte above $maxBytes. cURL only: without it the request is refused (the IPv4 rule and the address pinning
* are cURL options), and every host of the chain is pinned to the address that was checked (CURLOPT_RESOLVE,
* so a second DNS answer cannot redirect the connection). A failure reaches the caller only as null (no
* refused/timeout/status oracle, security audit M3); the reason goes to the log. Not final so abilities can be
* tested with a fake.
*/
class RemoteFileFetcher
{
@@ -54,22 +57,50 @@ class RemoteFileFetcher
return $this->failed($context, $url, 'URL refused');
}
// Audit L8: the IPv4-only rule and the pinning below are cURL options; another transport (fsockopen) would
// ignore them and could reach an AAAA address nobody checked.
if (! $this->curlAvailable()) {
return $this->failed($context, $url, 'no cURL transport');
}
// Audit DNS rebinding: allows() resolved the host once and cURL would resolve it again. Every host of this
// request chain (the URL, each redirect target) is resolved here once, checked and handed to cURL as
// CURLOPT_RESOLVE, so the connection goes to the address that was checked.
$pins = [];
$pin = function (string $target) use (&$pins): void {
$host = (string) wp_parse_url($target, PHP_URL_HOST);
$port = (int) wp_parse_url($target, PHP_URL_PORT)
?: (strtolower((string) wp_parse_url($target, PHP_URL_SCHEME)) === 'https' ? 443 : 80);
$key = $host . ':' . $port;
if (isset($pins[$key]) || filter_var(trim($host, '[]'), FILTER_VALIDATE_IP) !== false) {
return;
}
$addresses = ($this->resolve)($host);
if ($addresses === [] || array_filter($addresses, [PublicAddress::class, 'isPublicIp']) !== $addresses) {
throw new RequestsException('Host resolved to a non-public address', 'lit_mcp_rebinding');
}
$pins[$key] = $key . ':' . $addresses[0];
};
// IPv4 only, for this request only: wp_http_validate_url() checks the IPv4 address
// (gethostbyname), so an AAAA record must not lead cURL to an unchecked IPv6 target.
$ipv4 = static function ($handle): void {
$ipv4 = function ($handle, $args = null, $target = null) use (&$pins, $pin, $url): void {
if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle.
curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
$this->curlSetopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
}
$pin(is_string($target) ? $target : $url);
if ($pins !== [] && defined('CURLOPT_RESOLVE')) {
$this->curlSetopt($handle, CURLOPT_RESOLVE, array_values($pins));
}
};
// Every redirect target gets the plugin's own range check too; Requests turns the
// exception into a failed request (WP_Http returns a WP_Error).
$redirect = function ($location): void {
$redirect = function ($location) use ($pin): void {
if (! $this->isPublicUrl((string) $location)) {
throw new RequestsException('Redirect to a non-public address refused', 'lit_mcp_redirect');
}
$pin((string) $location);
};
add_action('http_api_curl', $ipv4);
add_action('http_api_curl', $ipv4, 10, 3);
add_action(self::REDIRECT_HOOK, $redirect);
try {
$response = wp_safe_remote_get($url, [
@@ -95,6 +126,19 @@ class RemoteFileFetcher
return $response;
}
/** Whether the cURL extension is there; a seam for tests (function_exists() cannot be undone). */
protected function curlAvailable(): bool
{
return function_exists('curl_init');
}
/** @param \CurlHandle $handle */
protected function curlSetopt($handle, int $option, mixed $value): void
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle.
curl_setopt($handle, $option, $value);
}
/** Host (name or IP literal) of $url resolves to public addresses only (PublicAddress). */
private function isPublicUrl(string $url): bool
{