tmp = (string) tempnam(sys_get_temp_dir(), 'lit-fetch'); $this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $this->previousErrorLog = ini_set('error_log', $this->errorLog); Functions\when('wp_parse_url')->alias('parse_url'); Functions\when('wp_http_validate_url')->returnArg(); Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']); } protected function tearDown(): void { ini_set('error_log', (string) $this->previousErrorLog); foreach ([$this->tmp, $this->errorLog] as $file) { if (is_file($file)) { unlink($file); } } parent::tearDown(); } /** @param list $addresses What every host name resolves to (no DNS in unit tests). */ private function fetcher(array $addresses = ['93.184.216.34']): RemoteFileFetcher { return new RemoteFileFetcher(static fn (string $host): array => $addresses); } public function testOnlyPublicHttpUrlsAreAllowed(): void { self::assertTrue($this->fetcher()->allows('https://fonts.example.com/inter.woff2')); self::assertTrue($this->fetcher()->allows('HTTP://fonts.example.com/inter.woff2')); self::assertFalse($this->fetcher()->allows('ftp://fonts.example.com/inter.woff2')); self::assertFalse($this->fetcher()->allows('file:///etc/passwd')); self::assertFalse($this->fetcher()->allows('http://169.254.169.254/latest/meta-data/')); self::assertFalse($this->fetcher(['10.0.0.5'])->allows('https://intern.example.com/x.woff2')); self::assertFalse($this->fetcher([])->allows('https://nirgends.example.com/x.woff2')); Functions\when('wp_http_validate_url')->justReturn(false); self::assertFalse($this->fetcher()->allows('https://fonts.example.com/inter.woff2')); } public function testTheBodyIsStreamedIntoTheTempFileAndCutOffOneByteAboveTheLimit(): void { Functions\expect('wp_safe_remote_get')->once()->with( 'https://fonts.example.com/inter.woff2', Mockery::on(function (array $args): bool { file_put_contents($args['filename'], 'wOF2DATA'); return $args['filename'] === $this->tmp && $args['stream'] === true && $args['timeout'] === 30 && $args['limit_response_size'] === 2048 + 1; }) )->andReturn(['response' => ['code' => 200], 'headers' => ['content-type' => 'font/woff2']]); $response = $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'font upload'); self::assertSame(['code' => 200], $response['response'] ?? null); self::assertSame('wOF2DATA', file_get_contents($this->tmp)); } public function testARefusedUrlIsNullAndNeverRequested(): void { Functions\expect('wp_safe_remote_get')->never(); self::assertNull($this->fetcher(['10.0.0.5'])->fetch('https://intern.example.com/x.woff2', $this->tmp, 2048, 'x')); self::assertNull($this->fetcher()->fetch('file:///etc/passwd', $this->tmp, 2048, 'x')); self::assertStringContainsString('URL refused', (string) file_get_contents($this->errorLog)); } public function testAFailedRequestIsNullAndOnlyTheLogNamesTheReasonAndTheContext(): void { Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Connection refused')); $response = $this->fetcher()->fetch( 'https://nutzer:geheim@fonts.example.com:8443/inter.woff2?token=abc', $this->tmp, 2048, 'font upload' ); $log = (string) file_get_contents($this->errorLog); self::assertNull($response); self::assertStringContainsString('font upload from https://fonts.example.com:8443/inter.woff2', $log); self::assertStringContainsString('Connection refused', $log); self::assertStringNotContainsString('geheim', $log); self::assertStringNotContainsString('token=abc', $log); } public function testAnHttpErrorStatusIsNull(): void { Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 404], 'headers' => []]); self::assertNull($this->fetcher()->fetch('https://fonts.example.com/fehlt.woff2', $this->tmp, 2048, 'x')); self::assertStringContainsString('HTTP status 404', (string) file_get_contents($this->errorLog)); } public function testTheIpv4AndRedirectHooksLiveOnlyDuringTheRequest(): void { $listener = null; Actions\expectAdded('http_api_curl')->once(); Actions\expectAdded('requests-requests.before_redirect')->once() ->whenHappen(static function (callable $callback) use (&$listener): void { $listener = $callback; }); Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 200], 'headers' => []]); $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'x'); self::assertFalse(has_action('http_api_curl')); self::assertFalse(has_action('requests-requests.before_redirect')); self::assertIsCallable($listener); $listener('https://cdn.example.com/inter.woff2'); // public: no exception $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 $dns * @return object{options: list}&RemoteFileFetcher */ private function recordingFetcher(\Closure $dns, bool $curl = true): RemoteFileFetcher { return new class ($dns, $curl) extends RemoteFileFetcher { /** @var list */ 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'); } } }