<?php
/**
 * Client IP detection, shared by the locker throttle and the Pro modules.
 *
 * @package Copyfend
 */

defined( 'ABSPATH' ) || exit;

/**
 * Best-effort, spoof-resistant client IP helper.
 */
class CShield_IP {

	/**
	 * Best-effort client IP.
	 *
	 * Defaults to REMOTE_ADDR. Cloudflare's CF-Connecting-IP header is only
	 * trusted when the request actually arrives from a Cloudflare edge IP —
	 * otherwise the header is attacker-spoofable and could be used to poison the
	 * honeypot blocklist or evade rate/brute-force throttling. Filterable for
	 * other reverse-proxy setups.
	 *
	 * @return string
	 */
	public static function client_ip() {
		$remote = ! empty( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		$remote = filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : '';

		$ip = $remote;

		if ( '' !== $remote && ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && self::is_cloudflare_ip( $remote ) ) {
			$cf = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
			if ( filter_var( $cf, FILTER_VALIDATE_IP ) ) {
				$ip = $cf;
			}
		}

		/**
		 * Filter the detected client IP (e.g. for custom reverse proxies).
		 *
		 * @param string $ip     Detected client IP.
		 * @param string $remote The raw REMOTE_ADDR.
		 */
		return (string) apply_filters( 'cshield_client_ip', $ip, $remote );
	}

	/**
	 * Whether an IP belongs to a published Cloudflare edge range.
	 *
	 * @param string $ip Validated IP address.
	 * @return bool
	 */
	private static function is_cloudflare_ip( $ip ) {
		$ranges = array(
			// Cloudflare's published IPv4 edge 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',
			// Cloudflare's published IPv6 edge ranges.
			'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
			'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
		);
		foreach ( $ranges as $cidr ) {
			if ( self::ip_in_cidr( $ip, $cidr ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * CIDR membership test that works for both IPv4 and IPv6.
	 *
	 * @param string $ip   Validated IP address.
	 * @param string $cidr Range in CIDR notation.
	 * @return bool
	 */
	private static function ip_in_cidr( $ip, $cidr ) {
		$parts = explode( '/', $cidr, 2 );
		if ( 2 !== count( $parts ) ) {
			return false;
		}
		$subnet = inet_pton( $parts[0] );
		$addr   = inet_pton( $ip );
		if ( false === $subnet || false === $addr || strlen( $subnet ) !== strlen( $addr ) ) {
			return false;
		}
		$bits  = (int) $parts[1];
		$bytes = intdiv( $bits, 8 );
		$rem   = $bits % 8;
		if ( $bytes > 0 && 0 !== substr_compare( $addr, $subnet, 0, $bytes ) ) {
			return false;
		}
		if ( $rem > 0 ) {
			$mask = chr( ( 0xff << ( 8 - $rem ) ) & 0xff );
			return ( $addr[ $bytes ] & $mask ) === ( $subnet[ $bytes ] & $mask );
		}
		return true;
	}
}
