<?php
namespace EncypherProvenance;

if (! defined('ABSPATH')) {
    exit;
}

/**
 * Validates that a configured API base URL cannot be turned into an SSRF
 * vector against the WordPress server or its hosting network.
 *
 * `api_base_url` is a `manage_options`-gated setting, but `manage_options`
 * does not imply server/network access: on a WordPress Multisite network
 * with delegated subsite admins, or on a managed-WordPress host where the
 * customer only has wp-admin, it can still be set by someone who should not
 * be able to make the server issue requests anywhere it likes. Every
 * request built from this base URL attaches the stored bearer API key (see
 * Rest::call_backend(), Rest::probe_health(), Rest::fetch_remote_account()),
 * so an unrestricted base URL lets that admin trigger an *authenticated*
 * outbound request to any host reachable from the WordPress server -
 * including cloud-metadata endpoints (e.g. 169.254.169.254) and internal
 * services on the hosting network.
 *
 * Validates EVERY resolved address (all A and AAAA records), not just the
 * first one: a hostname with a public IPv4 address but a private/ULA IPv6
 * address (or vice versa under round-robin DNS) would otherwise pass this
 * guard while the later wp_remote_get()/wp_remote_post() call independently
 * resolves the same hostname and may connect over the unchecked address
 * family or record.
 *
 * Residual risk (documented, accepted): validation resolves the hostname
 * via DNS once, at settings-save time. It does not close a DNS-rebinding
 * attack, where the attacker points the hostname at a public/safe IP while
 * saving (passing this check) and then repoints it at a private address
 * before the actual signing/quota HTTP request goes out later. Closing that
 * gap needs the HTTP client to resolve once and pin the connection to that
 * resolved IP for the lifetime of the request (e.g. CURLOPT_RESOLVE), which
 * WordPress's shared wp_remote_get() plumbing does not offer today. This
 * check is defense-in-depth at every call site that uses it (settings save
 * AND every outbound call site — see Rest::call_backend(),
 * Rest::probe_health(), Rest::fetch_remote_account()), not a complete fix.
 *
 * Deliberately dependency-free (only wp_parse_url(), WordPress's safe
 * polyfilled parse_url()) so it is unit-testable standalone: see
 * tests/test-api-base-url-ssrf-guard.php.
 */
class ApiUrlGuard
{
    /**
     * Match WordPress's default redirect ceiling while validating every hop
     * through this class instead of delegating to reject_unsafe_urls.
     */
    private const MAX_REDIRECT_HOPS = 5;

    /**
     * Whether $url is safe to use as an outbound API base URL.
     *
     * Rejects anything that is not exactly `https`, the literal host
     * `localhost` (case-insensitive), and any host with ANY resolved
     * address (IPv4 or IPv6) in a private, loopback, link-local, or
     * otherwise reserved range. A host with no resolvable address at all
     * fails closed (rejected), not open.
     *
     * @param string        $url      Candidate API base URL.
     * @param callable|null $resolver Overridable DNS resolver with the
     *                                signature `fn(string $host): string[]`
     *                                — every resolved IPv4/IPv6 address, or
     *                                an empty array when resolution fails.
     *                                Defaults to a wrapper around
     *                                `dns_get_record($host, DNS_A|DNS_AAAA)`,
     *                                which (unlike `gethostbyname()`) returns
     *                                every record instead of only the first
     *                                IPv4 answer. Tests inject a fake
     *                                resolver so this doesn't depend on real
     *                                DNS/network access.
     */
    public static function is_safe(string $url, ?callable $resolver = null): bool
    {
        $resolver = $resolver ?? [self::class, 'resolve_all'];

        // Opt-in dev/demo affordance: when ENCYPHER_ALLOW_INSECURE_API_BASE is
        // explicitly defined truthy in wp-config (NEVER in production), permit
        // pointing the plugin at a local dev API over http and at private/
        // loopback hosts (e.g. host.docker.internal). This restores the
        // documented local-demo workflow (demo/bootstrap_demo.sh) without
        // weakening the default posture: absent the constant, the full SSRF
        // guard below applies unchanged.
        if (defined('ENCYPHER_ALLOW_INSECURE_API_BASE') && ENCYPHER_ALLOW_INSECURE_API_BASE) {
            $dev_scheme = wp_parse_url($url, PHP_URL_SCHEME);
            $dev_host = wp_parse_url($url, PHP_URL_HOST);
            return in_array(strtolower((string) $dev_scheme), ['http', 'https'], true)
                && is_string($dev_host) && '' !== $dev_host;
        }

        $scheme = wp_parse_url($url, PHP_URL_SCHEME);
        if (! is_string($scheme) || 'https' !== strtolower($scheme)) {
            return false;
        }

        $host = wp_parse_url($url, PHP_URL_HOST);
        if (! is_string($host) || '' === $host) {
            return false;
        }
        // PHP preserves RFC 3986 brackets in PHP_URL_HOST for IPv6 literals.
        // Remove them before IP classification; DNS must never see a literal
        // loopback or unique-local address disguised as a hostname.
        if ('[' === $host[0] && ']' === substr($host, -1)) {
            $host = substr($host, 1, -1);
        }

        if ('localhost' === strtolower($host)) {
            return false;
        }

        // The host is itself a literal IP (v4 or v6) — validate directly,
        // no DNS involved.
        if (false !== filter_var($host, FILTER_VALIDATE_IP)) {
            return false !== filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
        }

        $ips = $resolver($host);
        if (! is_array($ips) || empty($ips)) {
            // Could not resolve at all — fail closed, not open.
            return false;
        }

        // ALL resolved addresses must be public. A single private/reserved
        // answer among several (dual-stack, round-robin) is enough to make
        // this host unsafe, since nothing here controls or predicts which
        // address the later HTTP client actually connects to.
        foreach ($ips as $ip) {
            if (! is_string($ip) || false === filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Default resolver: every IPv4 (A) and IPv6 (AAAA) address a hostname
     * resolves to. Returns an empty array on failure or when the function is
     * unavailable, which is_safe() treats as "fail closed".
     *
     * @return string[]
     */
    private static function resolve_all(string $host): array
    {
        if (! function_exists('dns_get_record')) {
            // Extremely locked-down PHP builds disable dns_get_record();
            // fail closed rather than silently skip validation.
            return [];
        }

        $records = @dns_get_record($host, DNS_A | DNS_AAAA);
        if (! is_array($records)) {
            return [];
        }

        $ips = [];
        foreach ($records as $record) {
            if (isset($record['ip']) && is_string($record['ip'])) {
                $ips[] = $record['ip'];
            } elseif (isset($record['ipv6']) && is_string($record['ipv6'])) {
                $ips[] = $record['ipv6'];
            }
        }

        return $ips;
    }

    /**
     * Force WordPress's HTTP client to return redirects to this wrapper.
     *
     * The initial URL and every resolved Location hop are checked by is_safe().
     * WordPress safe-URL validation remains enabled as defense in depth except
     * for the explicit local-development override, where it would otherwise
     * reject the private host that the override exists to permit. Automatic
     * redirects stay disabled so this guard remains the policy chokepoint.
     */
    private static function safe_http_args(array $args): array
    {
        $args['redirection'] = 0;
        $args['reject_unsafe_urls'] = ! (
            defined('ENCYPHER_ALLOW_INSECURE_API_BASE')
            && ENCYPHER_ALLOW_INSECURE_API_BASE
        );
        return $args;
    }

    /**
     * Guarded wrapper around wp_remote_get(). This is the ONE shared
     * chokepoint every class in the plugin that builds a URL from a stored
     * or request-supplied api_base_url should call through — Rest,
     * Coalition, EditLog, ErrorLog, SiteHealth, the WordPress/ai compat
     * layer, and the sign/verify Abilities API registrations. Centralizing
     * here (rather than duplicating a guard check at each of the ~10 call
     * sites across the plugin) means completeness is verifiable by grepping
     * for raw wp_remote_get()/wp_remote_post() calls instead of auditing
     * every class individually.
     */
    public static function guarded_remote_get(string $url, array $args = [])
    {
        return self::guarded_remote_request('GET', $url, $args);
    }

    /**
     * Guarded wrapper around wp_remote_post(). See guarded_remote_get().
     */
    public static function guarded_remote_post(string $url, array $args = [])
    {
        return self::guarded_remote_request('POST', $url, $args);
    }

    /**
     * Execute a guarded request and manually follow only safe redirect hops.
     */
    private static function guarded_remote_request(string $method, string $url, array $args = [])
    {
        if (! self::is_safe($url)) {
            return new \WP_Error(
                'unsafe_api_base_url',
                __('The Encypher API URL is not allowed (must be HTTPS and a public host).', 'encypher-provenance'),
                ['status' => 400]
            );
        }

        $method = strtoupper($method);
        $current_url = $url;
        $current_args = self::safe_http_args($args);

        for ($hop = 0; ; $hop++) {
            $response = self::dispatch_remote_request($method, $current_url, $current_args);
            if (self::is_error_response($response)) {
                return $response;
            }

            $status = self::response_code($response);
            if (! self::is_redirect_status($status)) {
                return $response;
            }

            if ($hop >= self::MAX_REDIRECT_HOPS) {
                return new \WP_Error(
                    'api_redirect_limit_exceeded',
                    __('The Encypher API redirect chain is too long.', 'encypher-provenance'),
                    ['status' => 400]
                );
            }

            $location = self::response_header($response, 'location');
            $next_url = self::resolve_redirect_url($current_url, $location);
            if (null === $next_url || ! self::is_safe($next_url)) {
                return new \WP_Error(
                    'unsafe_api_redirect_url',
                    __('The Encypher API redirect target is not allowed.', 'encypher-provenance'),
                    ['status' => 400]
                );
            }

            $next_method = $method;
            $next_args = $current_args;
            if ('POST' === $method && in_array($status, [301, 302, 303], true)) {
                $next_method = 'GET';
                unset($next_args['body']);
            }

            if (! self::same_origin($current_url, $next_url)) {
                $next_args = self::strip_cross_origin_credentials($next_args);
            }

            $method = $next_method;
            $current_url = $next_url;
            $current_args = self::safe_http_args($next_args);
        }
    }

    /**
     * Dispatch one non-redirecting HTTP request with the original WP transport.
     */
    private static function dispatch_remote_request(string $method, string $url, array $args)
    {
        if ('GET' === $method) {
            // On WordPress VIP, prefer the platform's hardened fetcher (enforced
            // timeouts + circuit breaking). Its failure fallback is returned as-is,
            // so pass a WP_Error to keep the wp_remote_get response contract callers
            // rely on (is_wp_error() / wp_remote_retrieve_*()).
            if (function_exists('vip_safe_wp_remote_get')) {
                $timeout  = isset($args['timeout']) ? (int) $args['timeout'] : 3;
                $fallback = new \WP_Error(
                    'http_request_failed',
                    __('The Encypher API request could not be completed.', 'encypher-provenance')
                );
                return vip_safe_wp_remote_get($url, $fallback, 3, $timeout, 20, $args);
            }

            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- SSRF-guarded via is_safe() above; vip_safe_wp_remote_get is preferred when available (VIP platform).
            return wp_remote_get($url, $args);
        }

        return wp_remote_post($url, $args);
    }

    private static function is_error_response($response): bool
    {
        if (function_exists('is_wp_error')) {
            return is_wp_error($response);
        }

        return $response instanceof \WP_Error;
    }

    private static function response_code($response): int
    {
        if (function_exists('wp_remote_retrieve_response_code')) {
            return (int) wp_remote_retrieve_response_code($response);
        }

        return is_array($response) && isset($response['response']['code'])
            ? (int) $response['response']['code']
            : 0;
    }

    private static function response_header($response, string $name): ?string
    {
        if (function_exists('wp_remote_retrieve_header')) {
            $header = wp_remote_retrieve_header($response, $name);
            if (is_array($header)) {
                $header = reset($header);
            }
            return is_string($header) && '' !== trim($header) ? trim($header) : null;
        }

        if (! is_array($response) || ! isset($response['headers']) || ! is_array($response['headers'])) {
            return null;
        }

        foreach ($response['headers'] as $key => $value) {
            if (is_string($key) && strtolower($key) === strtolower($name)) {
                if (is_array($value)) {
                    $value = reset($value);
                }
                return is_string($value) && '' !== trim($value) ? trim($value) : null;
            }
        }

        return null;
    }

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

    private static function resolve_redirect_url(string $base_url, ?string $location): ?string
    {
        if (! is_string($location)) {
            return null;
        }

        $location = trim($location);
        if ('' === $location || preg_match('/[\r\n]/', $location)) {
            return null;
        }

        $base_parts = wp_parse_url($base_url);
        if (! is_array($base_parts) || empty($base_parts['scheme']) || empty($base_parts['host'])) {
            return null;
        }

        $scheme = wp_parse_url($location, PHP_URL_SCHEME);
        if (is_string($scheme) && '' !== $scheme) {
            return $location;
        }

        if (0 === strpos($location, '//')) {
            return strtolower((string) $base_parts['scheme']) . ':' . $location;
        }

        $origin = self::origin_from_parts($base_parts);
        if (null === $origin) {
            return null;
        }

        $base_path = isset($base_parts['path']) && '' !== $base_parts['path'] ? (string) $base_parts['path'] : '/';
        if (0 === strpos($location, '#')) {
            $query = isset($base_parts['query']) && '' !== $base_parts['query'] ? '?' . $base_parts['query'] : '';
            return $origin . $base_path . $query . $location;
        }
        if (0 === strpos($location, '?')) {
            return $origin . $base_path . $location;
        }

        $path_and_query = $location;
        if (0 !== strpos($location, '/')) {
            $dir = preg_replace('#/[^/]*$#', '/', $base_path);
            $path_and_query = (is_string($dir) && '' !== $dir ? $dir : '/') . $location;
        }

        $query = '';
        $path = $path_and_query;
        $query_pos = strpos($path_and_query, '?');
        if (false !== $query_pos) {
            $path = substr($path_and_query, 0, $query_pos);
            $query = substr($path_and_query, $query_pos);
        }

        return $origin . self::remove_dot_segments($path) . $query;
    }

    private static function remove_dot_segments(string $path): string
    {
        $leading_slash = 0 === strpos($path, '/');
        $trailing_slash = substr($path, -1) === '/';
        $segments = explode('/', $path);
        $out = [];

        foreach ($segments as $segment) {
            if ('' === $segment || '.' === $segment) {
                continue;
            }
            if ('..' === $segment) {
                array_pop($out);
                continue;
            }
            $out[] = $segment;
        }

        $normalized = ($leading_slash ? '/' : '') . implode('/', $out);
        if ($trailing_slash && '' !== $normalized && '/' !== $normalized) {
            $normalized .= '/';
        }

        return '' === $normalized ? '/' : $normalized;
    }

    private static function same_origin(string $left, string $right): bool
    {
        $left_origin = self::url_origin($left);
        $right_origin = self::url_origin($right);

        return null !== $left_origin && $left_origin === $right_origin;
    }

    private static function url_origin(string $url): ?string
    {
        $parts = wp_parse_url($url);
        if (! is_array($parts)) {
            return null;
        }

        $scheme = isset($parts['scheme']) ? strtolower((string) $parts['scheme']) : '';
        $host = isset($parts['host']) ? strtolower((string) $parts['host']) : '';
        if ('' === $scheme || '' === $host) {
            return null;
        }

        $port = isset($parts['port']) ? (int) $parts['port'] : ('http' === $scheme ? 80 : 443);
        return $scheme . '://' . $host . ':' . $port;
    }

    private static function origin_from_parts(array $parts): ?string
    {
        if (empty($parts['scheme']) || empty($parts['host'])) {
            return null;
        }

        $origin = strtolower((string) $parts['scheme']) . '://' . strtolower((string) $parts['host']);
        if (isset($parts['port'])) {
            $origin .= ':' . (int) $parts['port'];
        }

        return $origin;
    }

    private static function strip_cross_origin_credentials(array $args): array
    {
        if (! isset($args['headers'])) {
            return $args;
        }

        if (is_array($args['headers'])) {
            foreach (array_keys($args['headers']) as $key) {
                if (is_string($key) && 'authorization' === strtolower($key)) {
                    unset($args['headers'][$key]);
                }
            }
        } elseif (is_string($args['headers'])) {
            $lines = preg_split('/\r\n|\r|\n/', $args['headers']);
            if (is_array($lines)) {
                $kept = [];
                foreach ($lines as $line) {
                    if (0 !== stripos($line, 'Authorization:')) {
                        $kept[] = $line;
                    }
                }
                $args['headers'] = implode("\r\n", $kept);
            }
        }

        return $args;
    }
}
