<?php
/**
 * SSRF guard / URL validator.
 *
 * Provides two layers of protection against Server-Side Request Forgery:
 *
 *  1. **On save** (`validate_external_url()`): schema check + hostname
 *     resolution → rejects private/loopback/link-local ranges.  Returns a
 *     descriptive WP_Error so the caller can surface a meaningful admin notice.
 *
 *  2. **Immediately before every outbound request** (`is_safe_url()`): the same
 *     checks are re-run to guard against DNS rebinding — i.e. a hostname that
 *     resolved to a public IP at save time but resolves to a private IP now.
 *
 * Private/reserved ranges blocked (RFC 1918, RFC 3927, RFC 4193, loopback):
 *   - 127.0.0.0/8        (IPv4 loopback)
 *   - 10.0.0.0/8         (RFC 1918)
 *   - 172.16.0.0/12      (RFC 1918)
 *   - 192.168.0.0/16     (RFC 1918)
 *   - 169.254.0.0/16     (link-local / APIPA / AWS metadata)
 *   - 100.64.0.0/10      (Shared Address Space, RFC 6598 — carrier-grade NAT)
 *   - ::1/128            (IPv6 loopback)
 *   - fc00::/7           (IPv6 ULA / RFC 4193)
 *   - fe80::/10          (IPv6 link-local)
 *
 * Known limitation (TOCTOU):
 *   DNS resolution happens at application level. There is an inherent race
 *   between validation and the actual HTTP request — a hostile actor with
 *   control of DNS could serve a public IP during resolution and switch to a
 *   private IP before `wp_remote_get()` opens the TCP connection (DNS
 *   rebinding). The double-check in `is_safe_url()` narrows the window but
 *   cannot eliminate it entirely. On this admin-only feature the residual risk
 *   is accepted; a full fix would require network-level egress filtering.
 *
 * @package Leadite
 */

namespace Leadite\Support;

defined( 'ABSPATH' ) || exit;

class UrlValidator {

	/**
	 * Validate a URL for use as an external outbound endpoint.
	 *
	 * Checks performed:
	 *  - Scheme must be `https`.
	 *  - Hostname must be present.
	 *  - Resolved IP must not fall in any private/reserved range.
	 *
	 * @param string $url Raw URL string.
	 * @return true|\WP_Error  `true` on success; a `WP_Error` with code and
	 *                         human-readable message on failure.
	 */
	public static function validate_external_url( $url ) {
		if ( empty( $url ) ) {
			return new \WP_Error( 'empty_url', __( 'URL cannot be empty.', 'leadite' ) );
		}

		// Scheme check: only HTTPS allowed.
		$scheme = wp_parse_url( $url, PHP_URL_SCHEME );
		if ( 'https' !== strtolower( (string) $scheme ) ) {
			return new \WP_Error(
				'bad_scheme',
				sprintf(
					/* translators: %s: the supplied scheme, e.g. "http" */
					__( 'Only HTTPS URLs are allowed (supplied scheme: "%s").', 'leadite' ),
					esc_html( (string) $scheme )
				)
			);
		}

		// Hostname must be present.
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( empty( $host ) ) {
			return new \WP_Error( 'no_host', __( 'URL must contain a valid hostname.', 'leadite' ) );
		}

		// Resolve and check.
		$ip_error = self::check_host_ip( $host );
		if ( is_wp_error( $ip_error ) ) {
			return $ip_error;
		}

		return true;
	}

	/**
	 * Lightweight boolean guard for use immediately before outbound requests.
	 *
	 * Re-resolves the hostname so that DNS-rebinding attacks (public IP at
	 * save-time, private IP at request-time) are caught at dispatch.
	 *
	 * @param string $url The stored URL.
	 * @return bool `true` if the URL is safe to request; `false` otherwise.
	 */
	public static function is_safe_url( $url ) {
		return true === self::validate_external_url( $url );
	}

	// -------------------------------------------------------------------------
	// Internal helpers
	// -------------------------------------------------------------------------

	/**
	 * Resolve $host to an IP and check whether it falls in a forbidden range.
	 *
	 * @param string $host Hostname or IP literal.
	 * @return true|\WP_Error
	 */
	protected static function check_host_ip( $host ) {
		// Strip IPv6 brackets (e.g. "[::1]" → "::1").
		$bare_host = ltrim( rtrim( $host, ']' ), '[' );

		// If host is already an IP literal, check it directly.
		if ( filter_var( $bare_host, FILTER_VALIDATE_IP ) ) {
			return self::check_ip( $bare_host );
		}

		// Preferred path: use dns_get_record() to fetch ALL A and AAAA records.
		// Checking every record prevents SSRF via split-horizon / round-robin DNS
		// where one of multiple addresses resolves to a private range.
		// Fail-fast: if ANY returned IP is private, the entire host is rejected.
		if ( function_exists( 'dns_get_record' ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors
			$all_records = @dns_get_record( $bare_host, DNS_A | DNS_AAAA );
			if ( is_array( $all_records ) && ! empty( $all_records ) ) {
				foreach ( $all_records as $record ) {
					$ip = '';
					if ( isset( $record['ip'] ) && $record['ip'] ) {
						$ip = $record['ip'];
					} elseif ( isset( $record['ipv6'] ) && $record['ipv6'] ) {
						$ip = $record['ipv6'];
					}
					if ( '' === $ip ) {
						continue;
					}
					$check = self::check_ip( $ip );
					if ( is_wp_error( $check ) ) {
						return $check; // Fail-fast: one bad IP → block the host.
					}
				}
				// Every returned A/AAAA record passed the range checks.
				return true;
			}
		}

		// Fallback: dns_get_record() unavailable or returned nothing.
		// gethostbyname() returns one IPv4 (or the original string on failure).
		$resolved_ipv4 = gethostbyname( $bare_host );
		if ( $resolved_ipv4 !== $bare_host ) {
			return self::check_ip( $resolved_ipv4 );
		}

		// Neither A nor AAAA record could be resolved — treat as unsafe.
		return new \WP_Error(
			'unresolvable_host',
			sprintf(
				/* translators: %s: the hostname */
				__( 'Could not resolve hostname "%s". Only publicly reachable hostnames are allowed.', 'leadite' ),
				esc_html( $bare_host )
			)
		);
	}

	/**
	 * Check whether a single IP address falls in a forbidden range.
	 *
	 * @param string $ip IPv4 or IPv6 address.
	 * @return true|\WP_Error
	 */
	protected static function check_ip( $ip ) {
		if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			return self::check_ipv6( $ip );
		}
		return self::check_ipv4( $ip );
	}

	/**
	 * Check IPv4 address against private/reserved CIDR ranges.
	 *
	 * @param string $ip IPv4 address.
	 * @return true|\WP_Error
	 */
	protected static function check_ipv4( $ip ) {
		$long = ip2long( $ip );
		if ( false === $long ) {
			return new \WP_Error( 'invalid_ip', __( 'Invalid IP address resolved from hostname.', 'leadite' ) );
		}

		// Private / reserved CIDR ranges to block.
		$blocked = array(
			array( '0.0.0.0',         8  ),  // "This" network (RFC 1122).
			array( '10.0.0.0',        8  ),  // RFC 1918 private.
			array( '100.64.0.0',      10 ),  // Shared Address Space (RFC 6598).
			array( '127.0.0.0',       8  ),  // Loopback.
			array( '169.254.0.0',     16 ),  // Link-local / APIPA / AWS metadata.
			array( '172.16.0.0',      12 ),  // RFC 1918 private.
			array( '192.0.0.0',       24 ),  // IETF Protocol Assignments.
			array( '192.168.0.0',     16 ),  // RFC 1918 private.
			array( '198.18.0.0',      15 ),  // Benchmarking (RFC 2544).
			array( '198.51.100.0',    24 ),  // Documentation (RFC 5737).
			array( '203.0.113.0',     24 ),  // Documentation (RFC 5737).
			array( '224.0.0.0',       4  ),  // Multicast.
			array( '240.0.0.0',       4  ),  // Reserved.
			array( '255.255.255.255', 32 ),  // Limited broadcast.
		);

		foreach ( $blocked as list( $network, $prefix ) ) {
			$mask    = $prefix === 0 ? 0 : ( ~( ( 1 << ( 32 - $prefix ) ) - 1 ) );
			$net_int = ip2long( $network );
			if ( ( $long & $mask ) === ( $net_int & $mask ) ) {
				return new \WP_Error(
					'private_ip',
					sprintf(
						/* translators: 1: IP address, 2: CIDR range */
						__( 'The URL resolves to a private or reserved IP address (%1$s — in range %2$s/%3$d). Only publicly routable addresses are allowed.', 'leadite' ),
						esc_html( $ip ),
						esc_html( $network ),
						(int) $prefix
					)
				);
			}
		}

		return true;
	}

	/**
	 * Check IPv6 address against private/reserved ranges.
	 *
	 * @param string $ip IPv6 address.
	 * @return true|\WP_Error
	 */
	protected static function check_ipv6( $ip ) {
		// Expand to packed binary for bitwise comparison.
		$packed = inet_pton( $ip );
		if ( false === $packed ) {
			return new \WP_Error( 'invalid_ipv6', __( 'Invalid IPv6 address.', 'leadite' ) );
		}

		// ::1/128 — loopback.
		if ( '::1' === inet_ntop( $packed ) || $packed === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" ) {
			return new \WP_Error(
				'private_ip',
				__( 'The URL resolves to the IPv6 loopback address (::1). Only publicly routable addresses are allowed.', 'leadite' )
			);
		}

		// First byte checks.
		$first_byte  = ord( $packed[0] );
		$first_two   = ( $first_byte << 8 ) | ord( $packed[1] );

		// fc00::/7 — Unique Local Addresses (RFC 4193).
		if ( ( $first_byte & 0xFE ) === 0xFC ) {
			return new \WP_Error(
				'private_ip',
				sprintf(
					/* translators: %s: IP address */
					__( 'The URL resolves to a private IPv6 address (%s — ULA fc00::/7). Only publicly routable addresses are allowed.', 'leadite' ),
					esc_html( $ip )
				)
			);
		}

		// fe80::/10 — link-local.
		if ( ( $first_two & 0xFFC0 ) === 0xFE80 ) {
			return new \WP_Error(
				'private_ip',
				sprintf(
					/* translators: %s: IP address */
					__( 'The URL resolves to a link-local IPv6 address (%s — fe80::/10). Only publicly routable addresses are allowed.', 'leadite' ),
					esc_html( $ip )
				)
			);
		}

		// ff00::/8 — multicast.
		if ( $first_byte === 0xFF ) {
			return new \WP_Error(
				'private_ip',
				sprintf(
					/* translators: %s: IP address */
					__( 'The URL resolves to an IPv6 multicast address (%s). Only publicly routable addresses are allowed.', 'leadite' ),
					esc_html( $ip )
				)
			);
		}

		return true;
	}
}