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
@@ -139,4 +139,102 @@ final class RemoteFileFetcherTest extends TestCase
$this->expectException(\WpOrg\Requests\Exception::class);
$listener('http://127.0.0.1/inter.woff2');
}
/**
* A fetcher whose cURL options are recorded; $dns is called per lookup (host => addresses).
*
* @param \Closure(string): list<string> $dns
* @return object{options: list<array{0: int, 1: mixed}>}&RemoteFileFetcher
*/
private function recordingFetcher(\Closure $dns, bool $curl = true): RemoteFileFetcher
{
return new class ($dns, $curl) extends RemoteFileFetcher {
/** @var list<array{0: int, 1: mixed}> */
public array $options = [];
public function __construct(\Closure $dns, private bool $curl)
{
parent::__construct($dns);
}
protected function curlAvailable(): bool
{
return $this->curl;
}
protected function curlSetopt($handle, int $option, mixed $value): void
{
$this->options[] = [$option, $value];
}
};
}
/** Audit L8: no cURL, no request – the IPv4 rule and the pinning are cURL options. */
public function testWithoutCurlNothingIsRequested(): void
{
Functions\expect('wp_safe_remote_get')->never();
$fetcher = $this->recordingFetcher(static fn (): array => ['93.184.216.34'], false);
self::assertNull($fetcher->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'font upload'));
self::assertStringContainsString('no cURL transport', (string) file_get_contents($this->errorLog));
}
/** @return array{0: callable, 1: callable} The http_api_curl and the redirect listener of one fetch(). */
private function listeners(RemoteFileFetcher $fetcher, string $url): array
{
$curl = $redirect = null;
Actions\expectAdded('http_api_curl')->once()->whenHappen(static function (callable $cb) use (&$curl): void {
$curl = $cb;
});
Actions\expectAdded('requests-requests.before_redirect')->once()
->whenHappen(static function (callable $cb) use (&$redirect): void {
$redirect = $cb;
});
Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 200], 'headers' => []]);
$fetcher->fetch($url, $this->tmp, 2048, 'x');
return [$curl, $redirect];
}
/** Audit (DNS rebinding): cURL connects to the address that was checked, IPv4 only. */
public function testTheCheckedAddressIsPinnedForTheHostAndEveryRedirectTarget(): void
{
$fetcher = $this->recordingFetcher(static fn (string $host): array => [
'fonts.example.com' => ['93.184.216.34', '93.184.216.35'], 'cdn.example.com' => ['198.51.101.7'],
][$host]);
[$curl, $redirect] = $this->listeners($fetcher, 'https://fonts.example.com/inter.woff2');
$curl(curl_init(), [], 'https://fonts.example.com/inter.woff2');
self::assertSame(
[[CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4], [CURLOPT_RESOLVE, ['fonts.example.com:443:93.184.216.34']]],
$fetcher->options
);
$fetcher->options = [];
$redirect('http://cdn.example.com:8080/inter.woff2');
$curl(curl_init(), [], 'https://fonts.example.com/inter.woff2'); // the redirect request: same original URL
self::assertSame(
[
[CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4],
[CURLOPT_RESOLVE, ['fonts.example.com:443:93.184.216.34', 'cdn.example.com:8080:198.51.101.7']],
],
$fetcher->options
);
}
public function testAHostThatTurnsPrivateAfterTheCheckIsNotConnected(): void
{
$lookups = 0;
$fetcher = $this->recordingFetcher(static function () use (&$lookups): array {
return ++$lookups === 1 ? ['93.184.216.34'] : ['127.0.0.1'];
});
[$curl] = $this->listeners($fetcher, 'https://rebind.example.com/a.woff2');
$this->expectException(\WpOrg\Requests\Exception::class);
try {
$curl(curl_init(), [], 'https://rebind.example.com/a.woff2');
} finally {
self::assertSame([[CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4]], $fetcher->options, 'no pin to the new address');
}
}
}