<?php
/**
 * Shared Client IP Resolver
 *
 * Single implementation of the "what IP is this request from" logic that the
 * three captcha modules previously duplicated (Simple, Google, Turnstile).
 *
 * Proxy headers (CF-Connecting-IP, X-Real-IP, X-Forwarded-For) are attacker-supplied
 * on any request, so they are only read when REMOTE_ADDR shows the request actually
 * arrived through a proxy: a Cloudflare edge address, a private/reserved address (an
 * in-network reverse proxy, which a public client cannot forge), or an address the
 * site owner declared trusted. Otherwise REMOTE_ADDR is used as-is.
 *
 * This matters because the resolved IP drives skip rules, rate limiting, and
 * allow/deny lists — trusting a spoofable header there is a bypass.
 *
 * @package WP_PowerSuite\Captcha\Shared
 * @since 1.1.0
 */

namespace WP_PowerSuite\Captcha\Shared;

/**
 * Client IP utility.
 *
 * Stateless static helpers — the class never holds state so the same
 * resolution logic is safe across the login, checkout, and AJAX lifecycles.
 */
final class ClientIp {

	/**
	 * Cloudflare edge ranges (https://www.cloudflare.com/ips/, retrieved 2026-07-29).
	 *
	 * Override with the `wp_powersuite_trusted_proxies` filter if the list moves on.
	 *
	 * @var list<string>
	 */
	private const CLOUDFLARE_RANGES = [
		'173.245.48.0/20',
		'103.21.244.0/22',
		'103.22.200.0/22',
		'103.31.4.0/22',
		'141.101.64.0/18',
		'108.162.192.0/18',
		'190.93.240.0/20',
		'188.114.96.0/20',
		'197.234.240.0/22',
		'198.41.128.0/17',
		'162.158.0.0/15',
		'104.16.0.0/13',
		'104.24.0.0/14',
		'172.64.0.0/13',
		'131.0.72.0/22',
		'2400:cb00::/32',
		'2606:4700::/32',
		'2803:f800::/32',
		'2405:b500::/32',
		'2405:8100::/32',
		'2a06:98c0::/29',
		'2c0f:f248::/32',
	];

	/**
	 * Return the best client IP for this request, or an empty string.
	 *
	 * Callers that want a guaranteed non-empty fallback can use {@see self::resolveOrDefault()}.
	 */
	public static function resolve(): string {
		$remote = self::headerIp( 'REMOTE_ADDR' );

		if ( self::shouldTrustProxyHeaders( $remote ) ) {
			foreach ( [ 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ] as $header ) {
				$ip = self::headerIp( $header );
				if ( $ip !== '' ) {
					return $ip;
				}
			}
		}

		return $remote;
	}

	/**
	 * Whether forwarded client headers on this request may be trusted.
	 *
	 * True only when REMOTE_ADDR is a known proxy hop (or the site owner opted in).
	 * A public client cannot forge REMOTE_ADDR, so a CF-* or X-Forwarded-* header
	 * on a direct connection is ignored.
	 */
	public static function isFromTrustedProxy(): bool {
		return self::shouldTrustProxyHeaders( self::headerIp( 'REMOTE_ADDR' ) );
	}

	/**
	 * Whether a trusted proxy reported this request as HTTPS.
	 *
	 * CF-Visitor and X-Forwarded-Proto/SSL are attacker-controlled on a direct
	 * connection, so they are never treated as proof of TLS by themselves.
	 */
	public static function isHttpsViaTrustedProxy(): bool {
		if ( ! self::isFromTrustedProxy() ) {
			return false;
		}

		if ( ! empty( $_SERVER['HTTP_CF_VISITOR'] ) ) {
			$cf_visitor = json_decode( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_VISITOR'] ) ), true );
			if ( is_array( $cf_visitor ) && ( $cf_visitor['scheme'] ?? '' ) === 'https' ) {
				return true;
			}
		}

		foreach ( [ 'HTTP_X_FORWARDED_PROTO', 'HTTP_X_FORWARDED_SSL' ] as $header ) {
			if ( empty( $_SERVER[ $header ] ) ) {
				continue;
			}
			$value = strtolower( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
			if ( $value === 'https' || $value === 'on' ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Validated IP from a $_SERVER key, or empty if missing/invalid.
	 *
	 * Callers must gate forwarded headers with {@see self::isFromTrustedProxy()}.
	 */
	public static function validatedIpFromServer( string $server_key ): string {
		return self::headerIp( $server_key );
	}

	/**
	 * Whether the proxy headers on this request came from something we trust.
	 *
	 * @param string $remote Validated REMOTE_ADDR, or an empty string.
	 */
	private static function shouldTrustProxyHeaders( string $remote ): bool {
		/**
		 * Trust every proxy header regardless of origin.
		 *
		 * Kept for back-compatibility; prefer `wp_powersuite_trusted_proxies`, because
		 * enabling this lets any client set its own IP.
		 *
		 * @param bool $trust_all Whether to trust proxy headers unconditionally.
		 */
		if ( (bool) apply_filters( 'wp_powersuite_trust_proxy_headers', false ) ) {
			return true;
		}

		if ( $remote === '' ) {
			return false;
		}

		// A private or reserved REMOTE_ADDR means the request reached PHP through a local
		// reverse proxy (nginx, a container network, a load balancer). A public client
		// cannot forge REMOTE_ADDR, so this is safe to key trust on.
		if ( self::isPrivateOrReserved( $remote ) ) {
			return true;
		}

		/**
		 * Reverse proxies whose forwarding headers may be trusted.
		 *
		 * @param list<string> $proxies IPs or CIDR ranges. Defaults to Cloudflare's edge ranges.
		 * @param string       $remote  The REMOTE_ADDR being evaluated.
		 */
		$proxies = apply_filters( 'wp_powersuite_trusted_proxies', self::CLOUDFLARE_RANGES, $remote );
		if ( ! is_array( $proxies ) ) {
			return false;
		}

		foreach ( $proxies as $range ) {
			$range = trim( (string) $range );
			if ( $range !== '' && self::ipMatchesRange( $remote, $range ) ) {
				return true;
			}
		}

		return false;
	}

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

	/**
	 * Match an IP against a single IP or a CIDR range (IPv4 and IPv6).
	 *
	 * @param string $ip    Validated IP address.
	 * @param string $range IP address or CIDR notation.
	 */
	private static function ipMatchesRange( string $ip, string $range ): bool {
		if ( ! str_contains( $range, '/' ) ) {
			return hash_equals( $range, $ip );
		}

		[ $subnet, $bits_raw ] = explode( '/', $range, 2 );

		$ip_bin     = @inet_pton( $ip );
		$subnet_bin = @inet_pton( trim( $subnet ) );
		if ( ! is_string( $ip_bin ) || ! is_string( $subnet_bin ) || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
			return false;
		}

		$bits = (int) $bits_raw;
		if ( $bits < 0 || $bits > strlen( $ip_bin ) * 8 ) {
			return false;
		}

		$whole_bytes = intdiv( $bits, 8 );
		if ( $whole_bytes > 0 && strncmp( $ip_bin, $subnet_bin, $whole_bytes ) !== 0 ) {
			return false;
		}

		$remaining_bits = $bits % 8;
		if ( $remaining_bits === 0 ) {
			return true;
		}

		$mask = chr( ( 0xff << ( 8 - $remaining_bits ) ) & 0xff );

		return ( $ip_bin[ $whole_bytes ] & $mask ) === ( $subnet_bin[ $whole_bytes ] & $mask );
	}

	private static function headerIp( string $key ): string {
		if ( empty( $_SERVER[ $key ] ) ) {
			return '';
		}
		$raw = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
		if ( strpos( $raw, ',' ) !== false ) {
			$parts = explode( ',', $raw );
			$raw   = trim( (string) $parts[0] );
		}
		if ( filter_var( $raw, FILTER_VALIDATE_IP ) ) {
			return $raw;
		}

		return '';
	}

	/**
	 * Resolve the client IP or return `0.0.0.0` when nothing is available.
	 *
	 * Used by siteverify calls (reCAPTCHA/Turnstile) which prefer the explicit
	 * sentinel over an empty field.
	 */
	public static function resolveOrDefault(): string {
		$ip = self::resolve();
		return $ip !== '' ? $ip : '0.0.0.0';
	}

	/**
	 * Resolve the client IP only if it is publicly routable, else an empty string.
	 *
	 * `remoteip` sent to siteverify must be the address the captcha provider saw
	 * solve the challenge. On local/dev stacks and misconfigured proxies we resolve
	 * a loopback or private address instead — sending that can only ever mismatch,
	 * which surfaces as intermittent verification failures. Omitting the optional
	 * field is always safe.
	 */
	public static function resolvePublicOrEmpty(): string {
		$ip = self::resolve();
		if ( $ip === '' || self::isPrivateOrReserved( $ip ) ) {
			return '';
		}
		return $ip;
	}
}
