| @@ -1,31 +1,103 @@ | ||
| 1 | -<?php | |
| 2 | - | |
| 3 | -namespace forge12\contactform7\CF7DoubleOptIn; | |
| 4 | - | |
| 5 | -if ( ! defined( 'ABSPATH' ) ) { | |
| 6 | - exit; | |
| 7 | -} | |
| 8 | - | |
| 9 | -class IPHelper { | |
| 10 | - /** | |
| 11 | - * Retrieves the IP address of the current client. | |
| 12 | - * | |
| 13 | - * The method checks if the IP address is obtained from a shared internet, proxy, or remote address. | |
| 14 | - * | |
| 15 | - * @return string The IP address of the current client. | |
| 16 | - */ | |
| 17 | - public static function getIPAdress(): string { | |
| 18 | - //whether ip is from share internet | |
| 19 | - if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) { | |
| 20 | - $ip_address = sanitize_text_field( $_SERVER['HTTP_CLIENT_IP'] ); | |
| 21 | - } //whether ip is from proxy | |
| 22 | - elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { | |
| 23 | - $ip_address = sanitize_text_field( $_SERVER['HTTP_X_FORWARDED_FOR'] ); | |
| 24 | - } //whether ip is from remote address | |
| 25 | - else { | |
| 26 | - $ip_address = sanitize_text_field( $_SERVER['REMOTE_ADDR'] ); | |
| 27 | - } | |
| 28 | - | |
| 29 | - return $ip_address; | |
| 30 | - } | |
| 31 | -} | |
| 1 | +<?php | |
| 2 | + | |
| 3 | +namespace forge12\contactform7\CF7DoubleOptIn; | |
| 4 | + | |
| 5 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 6 | + exit; | |
| 7 | +} | |
| 8 | + | |
| 9 | +class IPHelper { | |
| 10 | + /** | |
| 11 | + * Resolve the current client IP. | |
| 12 | + * | |
| 13 | + * Security: `REMOTE_ADDR` is the only value a remote client cannot spoof. | |
| 14 | + * `X-Forwarded-For` (and friends) are attacker-controllable, so we only | |
| 15 | + * consult XFF when the direct peer (`REMOTE_ADDR`) is itself a trusted | |
| 16 | + * proxy — configured via the `f12_doi_trusted_proxies` filter (list of | |
| 17 | + * IPs / CIDR ranges). Default is an EMPTY list, i.e. REMOTE_ADDR only. | |
| 18 | + * Sites behind a CDN/reverse proxy must register their proxy ranges: | |
| 19 | + * | |
| 20 | + * add_filter( 'f12_doi_trusted_proxies', fn() => array( '173.245.48.0/20' ) ); | |
| 21 | + * | |
| 22 | + * Rationale: many WordPress plugins were IP-spoofable by trusting XFF | |
| 23 | + * blindly — this value feeds the opt-in rate limiter and is stored as | |
| 24 | + * GDPR consent evidence, so it must not be forgeable. | |
| 25 | + * | |
| 26 | + * @return string | |
| 27 | + */ | |
| 28 | + public static function getIPAdress(): string { | |
| 29 | + $trusted = function_exists( 'apply_filters' ) | |
| 30 | + ? (array) apply_filters( 'f12_doi_trusted_proxies', array() ) | |
| 31 | + : array(); | |
| 32 | + | |
| 33 | + return self::resolveClientIp( isset( $_SERVER ) ? (array) $_SERVER : array(), $trusted ); | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * Pure IP resolution — no WordPress dependency, so it is unit-testable. | |
| 38 | + * | |
| 39 | + * @param array $server A `$_SERVER`-shaped array. | |
| 40 | + * @param array $trustedProxies IPs / IPv4-CIDR ranges considered trusted. | |
| 41 | + * @return string A validated IP, or '' when nothing valid is present. | |
| 42 | + */ | |
| 43 | + public static function resolveClientIp( array $server, array $trustedProxies = array() ): string { | |
| 44 | + $remote = isset( $server['REMOTE_ADDR'] ) ? trim( (string) $server['REMOTE_ADDR'] ) : ''; | |
| 45 | + $remote = filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : ''; | |
| 46 | + | |
| 47 | + // Only walk the forwarded chain when the direct peer is a known proxy. | |
| 48 | + if ( $remote !== '' | |
| 49 | + && ! empty( $trustedProxies ) | |
| 50 | + && self::ipInRanges( $remote, $trustedProxies ) | |
| 51 | + && ! empty( $server['HTTP_X_FORWARDED_FOR'] ) ) { | |
| 52 | + | |
| 53 | + $hops = array_reverse( | |
| 54 | + array_map( 'trim', explode( ',', (string) $server['HTTP_X_FORWARDED_FOR'] ) ) | |
| 55 | + ); | |
| 56 | + foreach ( $hops as $hop ) { | |
| 57 | + // First hop that is a valid IP and NOT itself a trusted proxy | |
| 58 | + // is the real client. | |
| 59 | + if ( filter_var( $hop, FILTER_VALIDATE_IP ) && ! self::ipInRanges( $hop, $trustedProxies ) ) { | |
| 60 | + return $hop; | |
| 61 | + } | |
| 62 | + } | |
| 63 | + } | |
| 64 | + | |
| 65 | + return $remote; | |
| 66 | + } | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * Match an IP against a list of exact IPs and IPv4 CIDR ranges. | |
| 70 | + * (IPv6 is matched exactly; extend here if IPv6 CIDR is needed.) | |
| 71 | + * | |
| 72 | + * @param string $ip | |
| 73 | + * @param array $ranges | |
| 74 | + * @return bool | |
| 75 | + */ | |
| 76 | + private static function ipInRanges( string $ip, array $ranges ): bool { | |
| 77 | + foreach ( $ranges as $range ) { | |
| 78 | + $range = trim( (string) $range ); | |
| 79 | + if ( $range === '' ) { | |
| 80 | + continue; | |
| 81 | + } | |
| 82 | + if ( strpos( $range, '/' ) === false ) { | |
| 83 | + if ( $ip === $range ) { | |
| 84 | + return true; | |
| 85 | + } | |
| 86 | + continue; | |
| 87 | + } | |
| 88 | + | |
| 89 | + list( $subnet, $bits ) = array_pad( explode( '/', $range, 2 ), 2, '' ); | |
| 90 | + $bits = (int) $bits; | |
| 91 | + $ipLong = ip2long( $ip ); | |
| 92 | + $subnetLong = ip2long( $subnet ); | |
| 93 | + if ( $ipLong === false || $subnetLong === false || $bits < 0 || $bits > 32 ) { | |
| 94 | + continue; // Not IPv4 or malformed CIDR — skip. | |
| 95 | + } | |
| 96 | + $mask = $bits === 0 ? 0 : ( -1 << ( 32 - $bits ) ); | |
| 97 | + if ( ( $ipLong & $mask ) === ( $subnetLong & $mask ) ) { | |
| 98 | + return true; | |
| 99 | + } | |
| 100 | + } | |
| 101 | + return false; | |
| 102 | + } | |
| 103 | +} | |