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:
co-authored by
Claude Sonnet 5.5
parent
06b798ab37
commit
20e02164a5
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user