diff --git a/app/Actions/UpdateLinkPreview.php b/app/Actions/UpdateLinkPreview.php index 7f2258163..21c9649b2 100644 --- a/app/Actions/UpdateLinkPreview.php +++ b/app/Actions/UpdateLinkPreview.php @@ -4,244 +4,107 @@ namespace App\Actions; -use App\Contracts\Services\DnsResolver; +use App\Contracts\Services\PreviewLink; +use App\Dto\LinkPreviewMetadata; +use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; -use Dom\HTMLDocument; -use GuzzleHttp\Exception\ResponseException; -use GuzzleHttp\Psr7\Utils; -use Illuminate\Http\Client\ConnectionException; +use App\Services\LinkPreviews\LinkPreviewAddress; +use App\Services\LinkPreviews\LinkPreviewBudget; +use App\Services\LinkPreviews\LinkPreviewHttpClient; +use App\Services\PreviewLinkProviders\HtmlPreviewLink; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Str; -use Symfony\Component\HttpFoundation\IpUtils; use Uri\WhatWg\Url; -/** - * @phpstan-type Metadata array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} - */ final readonly class UpdateLinkPreview { private const int MAX_REDIRECTS = 3; - private const int MAX_BODY_BYTES = 1_048_576; - - private const int MAX_IMAGE_URL_LENGTH = 2048; - - /** - * Non-public ranges that filter_var's private and reserved range flags let through: carrier-grade NAT, IETF protocol - * assignments, 6to4 relays, benchmarking, documentation and multicast for IPv4; NAT64, discard, Teredo, - * documentation, 6to4, site-local, multicast and IPv4-translated addresses for IPv6. - * - * @var list - */ - private const array BLOCKED_RANGES = [ - '100.64.0.0/10', - '192.0.0.0/24', - '192.0.2.0/24', - '192.88.99.0/24', - '198.18.0.0/15', - '198.51.100.0/24', - '203.0.113.0/24', - '224.0.0.0/4', - '64:ff9b::/96', - '64:ff9b:1::/48', - '100::/64', - '2001::/32', - '2001:db8::/32', - '2002::/16', - 'fec0::/10', - 'ff00::/8', - '::ffff:0:0:0/96', - ]; - - private const array NO_METADATA = [ - 'title' => null, - 'description' => null, - 'image_url' => null, - 'site_name' => null, - ]; - - public function __construct(private DnsResolver $dns) - { + private const string HEADER_ACCEPT = 'Accept'; + + public function __construct( + private LinkPreviewAddress $addresses, + private LinkPreviewBudget $budget, + private LinkPreviewHttpClient $http, + private HtmlPreviewLink $html, + ) { // } - /** - * Fetches the page behind the link and stores its Open Graph metadata. A page that can't or mustn't be fetched is - * marked as fetched without metadata, so it is not tried again. Failures that may pass, such as connection errors, - * timeouts, rate limits and server errors, are thrown instead, so the caller can try again later. - */ public function handle(LinkPreview $linkPreview): void { - $linkPreview->update([...$this->fetchMetadata($linkPreview->url), 'fetched_at' => now()]); + $metadata = $this->fetchMetadata($linkPreview->url); + + $linkPreview->update([...$metadata->toArray(), 'fetched_at' => now()]); } - /** - * Follows up to three redirects by hand, so that every hop goes through the same checks as the original URL. - * - * @return Metadata - */ - private function fetchMetadata(string $url): array + private function fetchMetadata(string $url): LinkPreviewMetadata { $page = Url::parse($url); + $deadline = $this->budget->start(); + $visited = []; for ($hop = 0; $hop <= self::MAX_REDIRECTS && $page instanceof Url; $hop++) { - $address = $this->publicAddress($page); + $source = $page->withFragment(null)->toAsciiString(); - if ($address === null) { - return self::NO_METADATA; + if (isset($visited[$source])) { + return new LinkPreviewMetadata; } - $response = $this->get($page, $address); + $visited[$source] = true; + $request = $this->prepareRequest($page, $deadline); - if ($response->serverError() || $response->tooManyRequests()) { - $response->throw(); + if (! $request instanceof LinkPreviewRequest) { + return new LinkPreviewMetadata; } - if (! $response->redirect()) { - return $response->successful() && $this->isHtml($response) - ? $this->parse($this->body($response), $page) - : self::NO_METADATA; - } - - $page = Url::parse($response->header('Location'), $page); - } - - return self::NO_METADATA; - } + $response = $this->http->get($request); + $response->throwIf($response->serverError() || $response->tooManyRequests()); - /** - * Returns the address to connect to, or null when the URL must not be fetched: anything but plain http(s) on a - * default port without credentials, or a host that resolves to nothing or to any non-public address. - */ - private function publicAddress(Url $url): ?string - { - if (! in_array($url->getScheme(), ['http', 'https'], true) - || ($url->getUsername() ?? '') !== '' - || ($url->getPassword() ?? '') !== '' - || ! in_array($url->getPort(), [null, 80, 443], true)) { - return null; - } - - $addresses = $this->dns->resolve(mb_trim((string) $url->getAsciiHost(), '[]')); - - return $addresses !== [] && array_all($addresses, $this->isPublic(...)) ? $addresses[0] : null; - } - - private function isPublic(string $address): bool - { - return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false - && ! IpUtils::checkIp($address, self::BLOCKED_RANGES); - } - - /** - * Connects to the address that was checked rather than resolving the host again, so DNS rebinding can't swap in a - * private address. The empty host and port in CURLOPT_CONNECT_TO match every request, so the pin holds even if - * cURL were to read the URL differently than we do. Bodies are cut off after 1 MB, and a body that was cut off - * (by that limit or by the timeout) is still used, as metadata lives at the top of the page. English is asked for - * explicitly, as sites such as YouTube otherwise pick the language from the server's location. - */ - private function get(Url $url, string $address): Response - { - $port = $url->getPort() ?? ($url->getScheme() === 'https' ? 443 : 80); - - $request = Http::withUserAgent(sprintf('Mozilla/5.0 (compatible; devnull-linkpreview/1.0; +%s)', config()->string('app.url'))) - ->accept('text/html') - ->withHeaders(['Accept-Language' => 'en-US,en;q=0.9']) - ->connectTimeout(3) - ->timeout(5) - ->withoutRedirecting() - ->withOptions([ - 'decode_content' => false, - 'progress' => fn (int $expected, int $downloaded): bool => $downloaded > self::MAX_BODY_BYTES, - 'curl' => [ - CURLOPT_CONNECT_TO => [sprintf('::%s:%d', str_contains($address, ':') ? "[{$address}]" : $address, $port)], - ], - ]); - - try { - return $request->get($url->toAsciiString()); - } catch (ConnectionException $connectionException) { - $cutOff = $connectionException->getPrevious(); - - if ($cutOff instanceof ResponseException) { - return new Response($cutOff->getResponse()); + if (! $response->redirect()) { + return $this->responseMetadata($response, $page, $this->html); } - throw $connectionException; + $page = $this->redirectPage($response, $page); } - } - - private function isHtml(Response $response): bool - { - $mediaType = mb_strtolower(mb_trim(Str::before($response->header('Content-Type'), ';'))); - - return in_array($mediaType, ['text/html', 'application/xhtml+xml'], true); - } - - private function body(Response $response): string - { - $body = $response->toPsrResponse()->getBody(); - $body->rewind(); - return Utils::copyToString($body, self::MAX_BODY_BYTES); + return new LinkPreviewMetadata; } - /** - * @return Metadata - */ - private function parse(string $html, Url $page): array + private function prepareRequest(Url $page, float $deadline): ?LinkPreviewRequest { - $document = HTMLDocument::createFromString($html, LIBXML_NOERROR); - $meta = []; + $address = $this->addresses->get($page); + $remaining = $this->budget->remaining($deadline); - foreach ($document->querySelectorAll('meta[content]') as $element) { - $key = mb_strtolower(mb_trim($element->getAttribute('property') ?? $element->getAttribute('name') ?? '')); - $meta[$key] ??= (string) $element->getAttribute('content'); + if ($address === null || $remaining <= 0) { + return null; } - return [ - 'title' => $this->text([$meta['og:title'] ?? '', $meta['twitter:title'] ?? '', $document->title], 300), - 'description' => $this->text([$meta['og:description'] ?? '', $meta['twitter:description'] ?? '', $meta['description'] ?? ''], 500), - 'image_url' => $this->imageUrl([$meta['og:image:secure_url'] ?? '', $meta['og:image'] ?? '', $meta['twitter:image'] ?? ''], $page), - 'site_name' => $this->text([$meta['og:site_name'] ?? ''], 100), - ]; + return new LinkPreviewRequest( + url: $page, + address: $address, + headers: [self::HEADER_ACCEPT => HtmlPreviewLink::CONTENT_TYPE], + maxBodyBytes: HtmlPreviewLink::MAX_BODY_BYTES, + timeoutSeconds: $remaining, + ); } - /** - * Returns the first candidate that still has text once entities are decoded and whitespace is collapsed, - * truncated to the column's length. - * - * @param list $candidates - */ - private function text(array $candidates, int $limit): ?string + private function redirectPage(Response $response, Url $page): ?Url { - foreach ($candidates as $candidate) { - $text = Str::squish(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + $location = mb_trim($response->header('Location')); - if ($text !== '') { - return mb_strlen($text) > $limit ? mb_rtrim(mb_substr($text, 0, $limit - 1)).'…' : $text; - } + if ($location === '') { + return null; } - return null; + return Url::parse($location, $page); } - /** - * Returns the first candidate that, resolved against the page URL, is an https URL. - * - * @param list $candidates - */ - private function imageUrl(array $candidates, Url $page): ?string + private function responseMetadata(Response $response, Url $page, PreviewLink $previewLink): LinkPreviewMetadata { - foreach ($candidates as $candidate) { - $image = mb_trim($candidate) === '' ? null : Url::parse($candidate, $page); - - if ($image?->getScheme() === 'https' && mb_strlen($image->toAsciiString()) <= self::MAX_IMAGE_URL_LENGTH) { - return $image->toAsciiString(); - } + if (! $response->successful()) { + return new LinkPreviewMetadata; } - return null; + return $previewLink->metadata($response, $page); } } diff --git a/app/Contracts/Services/PreviewLink.php b/app/Contracts/Services/PreviewLink.php new file mode 100644 index 000000000..500069a3c --- /dev/null +++ b/app/Contracts/Services/PreviewLink.php @@ -0,0 +1,14 @@ + + */ +final readonly class LinkPreviewMetadata implements Arrayable +{ + public function __construct( + public ?string $title = null, + public ?string $description = null, + public ?string $imageUrl = null, + public ?string $siteName = null, + ) { + // + } + + /** + * @return array{title: string|null, description: string|null, image_url: string|null, site_name: string|null} + */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'description' => $this->description, + 'image_url' => $this->imageUrl, + 'site_name' => $this->siteName, + ]; + } +} diff --git a/app/Dto/LinkPreviewRequest.php b/app/Dto/LinkPreviewRequest.php new file mode 100644 index 000000000..23f9f6f86 --- /dev/null +++ b/app/Dto/LinkPreviewRequest.php @@ -0,0 +1,23 @@ + $headers + */ + public function __construct( + public Url $url, + public string $address, + public array $headers, + public int $maxBodyBytes, + public float $timeoutSeconds, + ) { + // + } +} diff --git a/app/Services/LinkPreviews/LimitedResponseBody.php b/app/Services/LinkPreviews/LimitedResponseBody.php new file mode 100644 index 000000000..dfdd09e3e --- /dev/null +++ b/app/Services/LinkPreviews/LimitedResponseBody.php @@ -0,0 +1,25 @@ +maxBytes - $this->tell()); + + return parent::write(mb_substr($string, 0, $remaining, self::BINARY_ENCODING)); + } +} diff --git a/app/Services/LinkPreviews/LinkPreviewAddress.php b/app/Services/LinkPreviews/LinkPreviewAddress.php new file mode 100644 index 000000000..01e484ca3 --- /dev/null +++ b/app/Services/LinkPreviews/LinkPreviewAddress.php @@ -0,0 +1,59 @@ + + */ + private const array BLOCKED_RANGES = [ + ...IpUtils::PRIVATE_SUBNETS, + '64:ff9b::/32', // NAT64; FILTER_FLAG_GLOBAL_RANGE permits RFC 6052. + '192.88.99.0/24', + '224.0.0.0/4', + 'fec0::/10', + 'ff00::/8', + '::ffff:0:0:0/96', + ]; + + public function __construct(private DnsResolver $dns) + { + // + } + + public function get(Url $url): ?string + { + if (! in_array($url->getScheme(), ['http', 'https'], true)) { + return null; + } + + if (($url->getUsername() ?? '') !== '') { + return null; + } + + if (($url->getPassword() ?? '') !== '') { + return null; + } + + if ($url->getPort() !== null) { + return null; + } + + $addresses = $this->dns->resolve(mb_trim((string) $url->getAsciiHost(), '[]')); + + return $addresses !== [] && array_all($addresses, $this->isPublic(...)) ? $addresses[0] : null; + } + + private function isPublic(string $address): bool + { + return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE) !== false + && ! IpUtils::checkIp($address, self::BLOCKED_RANGES); + } +} diff --git a/app/Services/LinkPreviews/LinkPreviewBudget.php b/app/Services/LinkPreviews/LinkPreviewBudget.php new file mode 100644 index 000000000..385b042b3 --- /dev/null +++ b/app/Services/LinkPreviews/LinkPreviewBudget.php @@ -0,0 +1,42 @@ +seconds() + self::TIMEOUT_SECONDS; + } + + public function remaining(float $deadline): float + { + $milliseconds = floor(($deadline - $this->seconds()) * CarbonInterface::MILLISECONDS_PER_SECOND); + + return max(0, $milliseconds) / CarbonInterface::MILLISECONDS_PER_SECOND; + } + + private function seconds(): float + { + $nanoseconds = $this->clock instanceof Closure ? ($this->clock)() : hrtime(true); + + return $nanoseconds / self::NANOSECONDS_PER_SECOND; + } +} diff --git a/app/Services/LinkPreviews/LinkPreviewHttpClient.php b/app/Services/LinkPreviews/LinkPreviewHttpClient.php new file mode 100644 index 000000000..1f49a58b1 --- /dev/null +++ b/app/Services/LinkPreviews/LinkPreviewHttpClient.php @@ -0,0 +1,51 @@ + 'en-US,en;q=0.9']; + + public function get(LinkPreviewRequest $request): Response + { + $port = $request->url->getPort() ?? ($request->url->getScheme() === 'https' ? self::HTTPS_PORT : self::HTTP_PORT); + $connection = sprintf('::%s:%d', str_contains($request->address, ':') ? "[{$request->address}]" : $request->address, $port); + + try { + return Http::withUserAgent(sprintf(self::USER_AGENT_FORMAT, config()->string('app.url'))) + ->withHeaders([...self::DEFAULT_HEADERS, ...$request->headers]) + ->connectTimeout(min(self::CONNECT_TIMEOUT_SECONDS, $request->timeoutSeconds)) + ->timeout($request->timeoutSeconds) + ->withoutRedirecting() + ->withOptions([ + RequestOptions::DECODE_CONTENT => false, + RequestOptions::SINK => new LimitedResponseBody($request->maxBodyBytes), + RequestOptions::CURL => [CURLOPT_CONNECT_TO => [$connection]], + ]) + ->get($request->url->toAsciiString()); + } catch (ConnectionException $connectionException) { + $previous = $connectionException->getPrevious(); + + throw_unless($previous instanceof ResponseException, $connectionException); + + return new Response($previous->getResponse()); + } + } +} diff --git a/app/Services/PreviewLinkProviders/HtmlPreviewLink.php b/app/Services/PreviewLinkProviders/HtmlPreviewLink.php new file mode 100644 index 000000000..d9dc5b296 --- /dev/null +++ b/app/Services/PreviewLinkProviders/HtmlPreviewLink.php @@ -0,0 +1,104 @@ +header('Content-Type'), ';'))); + + if (! in_array($mediaType, [self::CONTENT_TYPE, 'application/xhtml+xml'], true)) { + return new LinkPreviewMetadata; + } + + $body = $response->toPsrResponse()->getBody(); + $body->rewind(); + + return $this->parse(Utils::copyToString($body, self::MAX_BODY_BYTES), $finalUrl); + } + + private function parse(string $html, Url $page): LinkPreviewMetadata + { + $document = HTMLDocument::createFromString($html, LIBXML_NOERROR); + $meta = []; + + foreach ($document->querySelectorAll('meta[content]') as $element) { + $key = mb_strtolower(mb_trim($element->getAttribute('property') ?? $element->getAttribute('name') ?? '')); + $meta[$key] ??= (string) $element->getAttribute('content'); + } + + return new LinkPreviewMetadata( + title: $this->text([$meta['og:title'] ?? '', $meta['twitter:title'] ?? '', $document->title], self::MAX_TITLE_LENGTH), + description: $this->text([$meta['og:description'] ?? '', $meta['twitter:description'] ?? '', $meta['description'] ?? ''], self::MAX_DESCRIPTION_LENGTH), + imageUrl: $this->imageUrl([$meta['og:image:secure_url'] ?? '', $meta['og:image'] ?? '', $meta['twitter:image'] ?? ''], $page), + siteName: $this->text([$meta['og:site_name'] ?? ''], self::MAX_SITE_NAME_LENGTH), + ); + } + + /** + * @param list $candidates + */ + private function text(array $candidates, int $limit): ?string + { + foreach ($candidates as $candidate) { + $text = Str::squish(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + + if ($text === '') { + continue; + } + + if (mb_strlen($text) <= $limit) { + return $text; + } + + return mb_rtrim(mb_substr($text, 0, $limit - 1)).'…'; + } + + return null; + } + + /** + * @param list $candidates + */ + private function imageUrl(array $candidates, Url $page): ?string + { + foreach ($candidates as $candidate) { + $image = mb_trim($candidate) === '' ? null : Url::parse($candidate, $page); + + if ($image?->getScheme() !== 'https') { + continue; + } + + if (mb_strlen($image->toAsciiString()) > self::MAX_IMAGE_URL_LENGTH) { + continue; + } + + return $image->toAsciiString(); + } + + return null; + } +} diff --git a/tests/Feature/Controllers/UserPreviewControllerTest.php b/tests/Feature/Controllers/UserPreviewControllerTest.php index b11bc9052..781b41abf 100644 --- a/tests/Feature/Controllers/UserPreviewControllerTest.php +++ b/tests/Feature/Controllers/UserPreviewControllerTest.php @@ -175,7 +175,7 @@ }); it('includes the shared local time in the user preview', function (): void { - User::factory()->create(['username' => 'octocat', 'timezone' => 'Europe/Berlin', 'show_local_time' => true]); + User::factory()->create(['handle' => 'octocat', 'timezone' => 'Europe/Berlin', 'show_local_time' => true]); $this->actingAs(User::factory()->create()) ->getJson('/@octocat/preview') diff --git a/tests/Unit/Actions/UpdateLinkPreviewTest.php b/tests/Unit/Actions/UpdateLinkPreviewTest.php index 6c1c7537d..63dd4d331 100644 --- a/tests/Unit/Actions/UpdateLinkPreviewTest.php +++ b/tests/Unit/Actions/UpdateLinkPreviewTest.php @@ -4,15 +4,21 @@ use App\Actions\UpdateLinkPreview; use App\Contracts\Services\DnsResolver; +use App\Dto\LinkPreviewRequest; use App\Models\LinkPreview; use App\Services\DnsResolvers\SystemDnsResolver; +use App\Services\LinkPreviews\LimitedResponseBody; +use App\Services\LinkPreviews\LinkPreviewBudget; +use App\Services\LinkPreviews\LinkPreviewHttpClient; use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response as PsrResponse; +use GuzzleHttp\RequestOptions; use Illuminate\Http\Client\HttpClientException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Uri\WhatWg\Url; beforeEach(function (): void { $this->app->instance(DnsResolver::class, new readonly class implements DnsResolver @@ -103,11 +109,12 @@ function expectNoMetadata(LinkPreview $linkPreview): void expect($options) ->allow_redirects->toBeFalse() ->connect_timeout->toBe(3) - ->timeout->toBe(5) + ->timeout->toBeGreaterThan(0)->toBeLessThanOrEqual(5) ->decode_content->toBeFalse() ->and($options['curl'][CURLOPT_CONNECT_TO])->toBe(['::93.184.216.34:443']) - ->and($options['progress'](0, 1_048_576, 0, 0))->toBeFalse() - ->and($options['progress'](2_000_000, 1_048_577, 0, 0))->toBeTrue(); + ->and($options['sink'])->toBeInstanceOf(LimitedResponseBody::class) + ->and($options['sink']->write(str_repeat('x', 1_048_577)))->toBe(1_048_576) + ->and($options['sink']->getSize())->toBe(1_048_576); }); it('pins the connection to the port and address that were checked', function (string $url, string $connectTo): void { @@ -301,6 +308,9 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'home network' => 'http://192.168.1.1/', 'cloud metadata' => 'http://169.254.169.254/latest/meta-data/', 'carrier-grade nat' => 'http://100.64.0.1/', + 'protocol assignment' => 'http://192.0.0.1/', + 'deprecated relay' => 'http://192.88.99.1/', + 'documentation' => 'http://192.0.2.1/', 'benchmarking' => 'http://198.18.0.1/', 'unspecified' => 'http://0.0.0.0/', 'multicast' => 'http://224.0.0.1/', @@ -309,14 +319,20 @@ function expectNoMetadata(LinkPreview $linkPreview): void 'ipv6 link-local' => 'http://[fe80::1]/', 'ipv4-mapped ipv6' => 'http://[::ffff:127.0.0.1]/', 'nat64' => 'http://[64:ff9b::a00:1]/', + 'nat64 local use' => 'http://[64:ff9b:1::a00:1]/', + 'ipv6 discard' => 'http://[100::1]/', + 'ipv6 site-local' => 'http://[fec0::1]/', + 'ipv4-translated ipv6' => 'http://[::ffff:0:a00:1]/', 'ipv6 multicast' => 'http://[ff02::1]/', 'localhost' => 'http://localhost/', 'host resolving to a private address' => 'https://internal.example.com/', 'host resolving partly to a private address' => 'https://mixed.example.com/', 'host resolving to nothing' => 'https://nowhere.example.com/', 'non-default port' => 'https://example.com:8080/', + 'https on http port' => 'https://example.com:80/', + 'http on https port' => 'http://example.com:443/', 'credentials' => 'https://user:secret@example.com/', - 'handle only' => 'https://user@example.com/', + 'username only' => 'https://user@example.com/', 'password only' => 'https://:secret@example.com/', 'ftp' => 'ftp://example.com/file', 'file' => 'file:///etc/passwd', @@ -430,3 +446,97 @@ public function resolve(string $host): array expect(fn () => resolve(UpdateLinkPreview::class)->handle($linkPreview))->toThrow(RuntimeException::class, 'The resolver broke.') ->and($linkPreview->refresh()->fetched_at)->toBeNull(); }); + +it('stops repeated redirect destinations before fetching them again', function (array $responses, int $requests): void { + Http::fake($responses); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + Http::assertSentCount($requests); +})->with([ + 'self loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/start.gif']), + ], 1], + 'fragment-only loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '#other']), + ], 1], + 'two host loop' => [fn (): array => [ + 'https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => 'https://www.example.com/next.gif']), + 'https://www.example.com/next.gif' => Http::response(status: 302, headers: ['Location' => 'https://example.com/start.gif']), + ], 2], +]); + +it('does not refetch redirects with a missing or invalid location', function (?string $location): void { + Http::fake(['https://example.com/*' => Http::response(status: 302, headers: $location === null ? [] : ['Location' => $location])]); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + Http::assertSentCount(1); +})->with([null, '', ' ', 'https://invalid host/cat.gif']); + +it('reduces each hop timeout to the remaining overall budget', function (): void { + $nanoseconds = 0; + $clock = function () use (&$nanoseconds): int { + return $nanoseconds; + }; + $this->app->instance(LinkPreviewBudget::class, new LinkPreviewBudget($clock)); + $timeouts = []; + $connectTimeouts = []; + Http::fake(function (Request $request, array $options) use (&$nanoseconds, &$timeouts, &$connectTimeouts): PromiseInterface { + $timeouts[] = $options['timeout']; + $connectTimeouts[] = $options['connect_timeout']; + $nanoseconds += 2_000_000_000; + + return Http::response(status: 302, headers: ['Location' => '/hop-'.count($timeouts).'.gif']); + }); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull() + ->and($timeouts)->toBe([5.0, 3.0, 1.0]) + ->and($connectTimeouts)->toBe([3, 3.0, 1.0]); + Http::assertSentCount(3); +}); + +it('does not connect if resolving an address exhausted the budget', function (): void { + $this->app->instance(LinkPreviewBudget::class, new LinkPreviewBudget(function (): int { + static $calls = 0; + + return $calls++ === 0 ? 0 : 6_000_000_000; + })); + Http::fake(); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + Http::assertNothingSent(); +}); + +it('rechecks changed DNS answers before following a redirect', function (): void { + $this->app->instance(DnsResolver::class, new class implements DnsResolver + { + private int $calls = 0; + + public function resolve(string $host): array + { + return $this->calls++ === 0 ? ['93.184.216.34'] : ['10.0.0.1']; + } + }); + Http::fake(['https://example.com/start.gif' => Http::response(status: 302, headers: ['Location' => '/next.gif'])]); + + expect(unfurl('https://example.com/start.gif')->title)->toBeNull(); + Http::assertSentCount(1); +}); + +it('pins the explicit port carried by a prepared request', function (): void { + $options = []; + Http::fake(function (Request $request, array $sent) use (&$options): PromiseInterface { + $options = $sent; + + return htmlResponse('Prepared request'); + }); + $request = new LinkPreviewRequest( + url: Url::parse('https://example.com:8443/'), + address: '93.184.216.34', + headers: ['Accept' => 'text/html'], + maxBodyBytes: 32, + timeoutSeconds: 5, + ); + + expect(resolve(LinkPreviewHttpClient::class)->get($request)->successful())->toBeTrue() + ->and($options[RequestOptions::CURL][CURLOPT_CONNECT_TO])->toBe(['::93.184.216.34:8443']); +}); diff --git a/tests/Unit/Services/LinkPreviews/LimitedResponseBodyTest.php b/tests/Unit/Services/LinkPreviews/LimitedResponseBodyTest.php new file mode 100644 index 000000000..eabdb26f0 --- /dev/null +++ b/tests/Unit/Services/LinkPreviews/LimitedResponseBodyTest.php @@ -0,0 +1,39 @@ +write($header))->toBe(13) + ->and((string) $body)->toBe($header); +}); + +it('returns a short write so the transport aborts an oversized response', function (): void { + $body = new LimitedResponseBody(32); + + expect($body->write('GIF89a'.str_repeat('x', 1_048_576)))->toBe(32) + ->and($body->getSize())->toBe(32) + ->and((string) $body)->toBe('GIF89a'.str_repeat('x', 26)); +}); + +it('enforces the same cap across multiple writes', function (): void { + $body = new LimitedResponseBody(32); + + expect($body->write(str_repeat('a', 20)))->toBe(20) + ->and($body->write(str_repeat('b', 20)))->toBe(12) + ->and($body->write('c'))->toBe(0) + ->and($body->getSize())->toBe(32) + ->and((string) $body)->toBe(str_repeat('a', 20).str_repeat('b', 12)); +}); + +it('does not let a seek move writes past the byte cap', function (): void { + $body = new LimitedResponseBody(32); + $body->seek(64); + + expect($body->write('overflow'))->toBe(0) + ->and($body->getSize())->toBe(0); +}); diff --git a/tests/Unit/Services/LinkPreviews/LinkPreviewAddressTest.php b/tests/Unit/Services/LinkPreviews/LinkPreviewAddressTest.php new file mode 100644 index 000000000..04d83f388 --- /dev/null +++ b/tests/Unit/Services/LinkPreviews/LinkPreviewAddressTest.php @@ -0,0 +1,94 @@ +address]; + } + }); + + expect($validator->get(Url::parse('https://example.com/cat.gif')))->toBeNull(); +})->with([ + 'carrier-grade NAT' => '100.64.0.1', + 'IETF protocol assignment' => '192.0.0.1', + 'IPv4 documentation one' => '192.0.2.1', + '6to4 relay' => '192.88.99.1', + 'benchmarking' => '198.18.0.1', + 'IPv4 documentation two' => '198.51.100.1', + 'IPv4 documentation three' => '203.0.113.1', + 'IPv4 multicast' => '224.0.0.1', + 'RFC 6052 loopback' => '64:ff9b::7f00:1', + 'local-use NAT64 loopback' => '64:ff9b:1::7f00:1', + 'broader NAT64 prefix one' => '64:ff9b:7f00:1::', + 'broader NAT64 prefix two' => '64:ff9b::1:7f00:1', + 'RFC 6052 private address' => '64:ff9b::a00:1', + 'discard-only' => '100::1', + 'Teredo' => '2001::1', + 'IPv6 documentation' => '2001:db8::1', + 'IPv6 6to4' => '2002::1', + 'site-local' => 'fec0::1', + 'IPv6 multicast' => 'ff00::1', + 'IPv4-compatible address' => '::2', + 'IPv4-mapped address' => '::ffff:127.0.0.1', + 'IPv4-translated address' => '::ffff:0:7f00:1', + 'IPv4 loopback' => '127.0.0.1', + 'IPv6 loopback' => '::1', +]); + +it('rejects a host when any resolved address is not public', function (): void { + $validator = new LinkPreviewAddress(new readonly class implements DnsResolver + { + public function resolve(string $host): array + { + return ['93.184.216.34', '64:ff9b::7f00:1']; + } + }); + + expect($validator->get(Url::parse('https://example.com/cat.gif')))->toBeNull(); +}); + +it('accepts public addresses on default HTTP ports', function (string $url, string $address): void { + $validator = new LinkPreviewAddress(new readonly class($address) implements DnsResolver + { + public function __construct(private string $address) + { + // + } + + public function resolve(string $host): array + { + return [$this->address]; + } + }); + + expect($validator->get(Url::parse($url)))->toBe($address); +})->with([ + 'IPv4 HTTPS default port' => ['https://example.com:443/cat.gif', '93.184.216.34'], + 'IPv6 HTTPS default port' => ['https://example.com:443/cat.gif', '2606:2800:220:1:248:1893:25c8:1946'], + 'IPv4 HTTP default port' => ['http://example.com:80/cat.gif', '93.184.216.34'], +]); + +it('rejects non-default ports', function (): void { + $validator = new LinkPreviewAddress(new readonly class implements DnsResolver + { + public function resolve(string $host): array + { + return ['93.184.216.34']; + } + }); + + expect($validator->get(Url::parse('https://example.com:8443/cat.gif')))->toBeNull(); +}); diff --git a/tests/Unit/Services/LinkPreviews/LinkPreviewBudgetTest.php b/tests/Unit/Services/LinkPreviews/LinkPreviewBudgetTest.php new file mode 100644 index 000000000..88eea61c6 --- /dev/null +++ b/tests/Unit/Services/LinkPreviews/LinkPreviewBudgetTest.php @@ -0,0 +1,28 @@ +start(); + $nanoseconds = 2_000_000_000; + + expect($deadline)->toBe(5.0) + ->and($budget->remaining($deadline))->toBe(3.0); + + $nanoseconds = 6_000_000_000; + + expect($budget->remaining($deadline))->toBe(0.0); +}); + +it('does not pass a sub-millisecond timeout to the HTTP client', function (int $nanoseconds): void { + $budget = new LinkPreviewBudget(fn (): int => $nanoseconds); + + expect($budget->remaining(5.0))->toBe(0.0); +})->with([4_999_999_999, 4_999_100_000]);