<?php

namespace App\Support;

use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use RuntimeException;

/**
 * Fetching a URL a stranger chose, without reaching inside our own network.
 *
 * Every guard here was checked before the request and then not again, while the
 * client followed redirects. So the check covered the host the user typed and
 * nothing after it: a page on a perfectly ordinary public domain could answer
 * 302 to http://169.254.169.254/latest/meta-data/ or http://127.0.0.1:6379/ and
 * the body came back into the chat as the landing page analysis. The user never
 * types a private address, so the guard never fires.
 *
 * Kept in one place because the same mistake was in three: LandingPageAnalyzer,
 * SameHostPageFetcher and PuppeteerListingScraper each grew their own version
 * of the check and each stopped at the first hop.
 *
 * The second half of the problem was that the host was resolved to decide and
 * resolved again to connect, so a name with a one-second TTL could answer
 * publicly for the check and 127.0.0.1 for the fetch. Checking harder cannot fix
 * that - there is no check that holds across two lookups - so `send()` resolves
 * once and hands curl the address it verified, hop by hop, with curl's own
 * redirect following turned off. `redirectOptions()` is kept for a caller that
 * lets the client follow redirects itself, and it still only closes the first
 * half.
 */
final class PublicHttp
{
    /**
     * Whether a host resolves somewhere we are willing to fetch from.
     *
     * Names are checked before resolution as well, because "localhost" and the
     * .local suffix do not always resolve to something FILTER_FLAG_NO_PRIV_RANGE
     * would catch.
     */
    public static function isPublicHost(?string $host): bool
    {
        $host = strtolower(rtrim(trim((string) $host), '.'));

        // An IPv6 literal arrives bracketed from a URL - getHost() on
        // http://[::1]/ is "[::1]" - and filter_var does not recognise it in
        // that form. Unstripped it failed the IP check, fell through to a name
        // lookup that resolved to nothing, and was allowed.
        if (str_starts_with($host, '[') && str_ends_with($host, ']')) {
            $host = substr($host, 1, -1);
        }

        if ($host === '' || $host === 'localhost' || str_ends_with($host, '.local')) {
            return false;
        }

        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return self::isPublicAddress($host);
        }

        // Every address the name answers with, not just the first, and AAAA as
        // well as A. gethostbyname reads A records only, so an IPv6-only name
        // pointing at ::1 came back "unresolvable" and would have been waved
        // through by the rule below.
        foreach (self::addressesFor($host) as $address) {
            if (! self::isPublicAddress($address)) {
                return false;
            }
        }

        // A name that resolves to nothing is not private, it is unreachable,
        // and the connection will fail on its own. Refusing here instead would
        // turn a DNS blip into "use a public landing page URL", which is advice
        // the buyer cannot act on because they already did.
        return true;
    }

    private static function isPublicAddress(string $address): bool
    {
        return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
    }

    /**
     * Both record types, because either can carry the connection.
     *
     * @return list<string>
     */
    private static function addressesFor(string $host): array
    {
        $addresses = [];

        foreach ([DNS_A, DNS_AAAA] as $type) {
            foreach (@dns_get_record($host, $type) ?: [] as $record) {
                $address = $record['ip'] ?? $record['ipv6'] ?? null;

                if (is_string($address) && $address !== '') {
                    $addresses[] = $address;
                }
            }
        }

        return $addresses;
    }

    /** The same question asked of a whole URL, scheme included. */
    public static function isPublicUrl(string $url): bool
    {
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));

        if (! in_array($scheme, ['http', 'https'], true)) {
            return false;
        }

        return self::isPublicHost(parse_url($url, PHP_URL_HOST));
    }

    /**
     * Fetch a URL, following its redirects ourselves, one verified hop at a time.
     *
     * The point of doing the following by hand is the pinning. Each hop is
     * resolved once, every address it answers with is checked, and curl is then
     * told which address to connect to - so the name is never looked up a second
     * time and there is no window between deciding and connecting. That window is
     * the whole of DNS rebinding, and no amount of checking closes it.
     *
     * The caller brings its own headers and timeouts; this only takes over the
     * redirect handling, because that is the part that has to stay guarded.
     *
     * Every hop is a GET, which is all either caller ever sends, so the method
     * rules for 303 against 307 and 308 do not arise. A caller that needs to
     * redirect a POST needs this to carry the method and body, and it does not.
     */
    public static function send(PendingRequest $request, string $url, int $max = 5): Response
    {
        $seen = [];

        for ($hop = 0; $hop <= $max; $hop++) {
            // One resolution per hop, checked and pinned from the same answer.
            //
            // This called isPublicUrl() and then pinnedOptions(), which resolved
            // the name twice - four dns_get_record calls a hop, twenty on a
            // five-hop chain, about 10ms each time here. The cost was the smaller
            // problem. Two lookups meant the addresses that were checked and the
            // address that was pinned came from different answers, so a name that
            // changed between them got a pin nobody had vetted: the rebinding
            // window this method exists to close, reopened inside it.
            $resolved = self::resolveOnce($url);

            if (! $resolved['ok']) {
                throw new RuntimeException(
                    'The address ['.(parse_url($url, PHP_URL_HOST) ?: $url).'] is not publicly reachable, '
                    .'so the fetch was abandoned.'
                );
            }

            // A chain that revisits a URL is a loop, and the hop count alone
            // would spend every remaining hop discovering that.
            if (isset($seen[$url])) {
                throw new RuntimeException('The redirect chain returned to ['.$url.'], so the fetch was abandoned.');
            }

            $seen[$url] = true;

            $response = $request
                ->withoutRedirecting()
                ->withOptions(self::pinFor($resolved))
                ->get($url);

            if (! self::isRedirect($response->status()) || blank($location = $response->header('Location'))) {
                return $response;
            }

            $url = self::absolute($url, $location);
        }

        throw new RuntimeException('The fetch followed '.$max.' redirects without arriving, so it was abandoned.');
    }

    /**
     * Resolve a URL's host once, and say whether we are willing to reach it.
     *
     * The single place the name is looked up. Everything the decision and the
     * connection both need comes out of here together, because the two using
     * different answers is exactly the hole.
     *
     * @return array{ok: bool, host: string, port: int, addresses: list<string>}
     */
    private static function resolveOnce(string $url): array
    {
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
        $host = strtolower(rtrim(trim((string) parse_url($url, PHP_URL_HOST)), '.'));
        $host = str_starts_with($host, '[') && str_ends_with($host, ']') ? substr($host, 1, -1) : $host;
        $port = (int) (parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80));
        $nothing = ['ok' => false, 'host' => $host, 'port' => $port, 'addresses' => []];

        if (! in_array($scheme, ['http', 'https'], true)) {
            return $nothing;
        }

        if ($host === '' || $host === 'localhost' || str_ends_with($host, '.local')) {
            return $nothing;
        }

        // An address written into the URL needs no lookup, and there was never a
        // race to close for it.
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return self::isPublicAddress($host)
                ? ['ok' => true, 'host' => $host, 'port' => $port, 'addresses' => []]
                : $nothing;
        }

        $addresses = self::addressesFor($host);

        foreach ($addresses as $address) {
            if (! self::isPublicAddress($address)) {
                return $nothing;
            }
        }

        // A name that resolves to nothing is unreachable rather than private; see
        // isPublicHost(). Nothing to pin either, so curl resolves it and fails.
        return ['ok' => true, 'host' => $host, 'port' => $port, 'addresses' => $addresses];
    }

    /**
     * Tell curl which address to use, so it does not resolve the name again.
     *
     * CURLOPT_RESOLVE takes `host:port:address`, and an IPv6 address has to be
     * bracketed there.
     *
     * The address comes from the same lookup the check above used, which is the
     * whole point: pinning an address from a second lookup would pin one nothing
     * had vetted.
     *
     * @param  array{ok: bool, host: string, port: int, addresses: list<string>}  $resolved
     * @return array<string, mixed>
     */
    private static function pinFor(array $resolved): array
    {
        if ($resolved['addresses'] === []) {
            return [];
        }

        $host = $resolved['host'];
        $port = $resolved['port'];
        $address = $resolved['addresses'][0];

        return ['curl' => [
            CURLOPT_RESOLVE => [sprintf(
                '%s:%d:%s',
                $host,
                $port,
                str_contains($address, ':') ? '['.$address.']' : $address,
            )],
        ]];
    }

    private static function isRedirect(int $status): bool
    {
        return in_array($status, [301, 302, 303, 307, 308], true);
    }

    /** A Location header resolved against the URL it came from. */
    private static function absolute(string $base, string $location): string
    {
        return (string) UriResolver::resolve(new Uri($base), new Uri(trim($location)));
    }

    /**
     * Client options that keep every hop of a redirect chain public.
     *
     * protocols pins the chain to http and https, so a redirect cannot step
     * sideways into file:// or ftp://. on_redirect throws, which is how Guzzle
     * is told to abandon a chain part way.
     *
     * This checks each hop but does not pin its address, so the resolve-twice
     * window is still open on every hop. Prefer send().
     *
     * @return array<string, mixed>
     */
    public static function redirectOptions(int $max = 5): array
    {
        return [
            'allow_redirects' => [
                'max' => $max,
                'strict' => true,
                'referer' => false,
                'protocols' => ['http', 'https'],
                'on_redirect' => static function (RequestInterface $request, ResponseInterface $response, UriInterface $uri): void {
                    if (! self::isPublicHost($uri->getHost())) {
                        throw new RuntimeException(
                            'Redirected to a non-public address ['.$uri->getHost().'], so the fetch was abandoned.'
                        );
                    }
                },
            ],
        ];
    }
}
