PluginProbe
The Innovative Form Builder – IvyForms / 0.8
The Innovative Form Builder – IvyForms v0.8
1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 1.2 1.3
ivyforms / backend / src / Services / Security / IpDetectionService.php

IpDetectionService.php in The Innovative Form Builder – IvyForms 0.8, at backend/src/Services/Security/IpDetectionService.php

329 lines 9.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Services\Security;
9
10 // phpcs:disable PSR1.Files.SideEffects
11 if (!defined('ABSPATH')) {
12 exit; // Exit if accessed directly
13 }
14
15 /**
16 * Enhanced IP Detection Service
17 *
18 * Provides comprehensive IP address detection with support for various proxy
19 * configurations, CDN services (Cloudflare), and security filtering options.
20 *
21 * @package IvyForms\Services\Security
22 * @SuppressWarnings(PHPMD)
23 */
24 class IpDetectionService
25 {
26 /**
27 * IP source priority order for detection
28 */
29 private const IP_SOURCES = [
30 'HTTP_CF_CONNECTING_IP', // Cloudflare connecting IP (highest priority if valid)
31 'HTTP_CLIENT_IP', // Shared internet IP
32 'HTTP_X_REAL_IP', // Real IP header (Nginx proxy)
33 'HTTP_X_FORWARDED_FOR', // Proxy forwarded IP
34 'HTTP_X_FORWARDED', // Alternative proxy header
35 'HTTP_X_CLUSTER_CLIENT_IP', // Cluster client IP
36 'HTTP_FORWARDED_FOR', // Another proxy variant
37 'HTTP_FORWARDED', // Standard forwarded header
38 'REMOTE_ADDR' // Direct connection IP (fallback)
39 ];
40
41 /**
42 * Cloudflare IP ranges for validation
43 */
44 private const CLOUDFLARE_IP_RANGES = [
45 '199.27.128.0/21',
46 '173.245.48.0/20',
47 '103.21.244.0/22',
48 '103.22.200.0/22',
49 '103.31.4.0/22',
50 '141.101.64.0/18',
51 '108.162.192.0/18',
52 '190.93.240.0/20',
53 '188.114.96.0/20',
54 '197.234.240.0/22',
55 '198.41.128.0/17',
56 '162.158.0.0/15',
57 '104.16.0.0/12',
58 '172.64.0.0/13',
59 '131.0.72.0/22'
60 ];
61
62 /**
63 * Required Cloudflare headers for validation
64 */
65 private const CLOUDFLARE_HEADERS = [
66 'HTTP_CF_CONNECTING_IP',
67 'HTTP_CF_IPCOUNTRY',
68 'HTTP_CF_RAY',
69 'HTTP_CF_VISITOR'
70 ];
71
72 /**
73 * Get user IP address with enhanced detection
74 *
75 * @param bool $allowPrivate Whether to allow private IP addresses (default: false)
76 * @param bool $validateCloudflare Whether to validate Cloudflare IPs (default: true)
77 * @return string The detected IP address or empty string if none found
78 */
79 public static function getUserIpAddress(bool $allowPrivate = false, bool $validateCloudflare = true): string
80 {
81 // Try Cloudflare IP first if detected and validation is enabled
82 if ($validateCloudflare) {
83 $cloudflareIp = self::getCloudflareIp();
84 if ($cloudflareIp !== null) {
85 return $cloudflareIp;
86 }
87 }
88
89 // Check all IP sources in priority order
90 foreach (self::IP_SOURCES as $source) {
91 $ip = self::getServerVariable($source);
92
93 if (empty($ip)) {
94 continue;
95 }
96
97 // Handle comma-separated IPs (common in X-Forwarded-For)
98 if (strpos($ip, ',') !== false) {
99 $ips = array_map('trim', explode(',', $ip));
100 foreach ($ips as $candidateIp) {
101 $validIp = self::validateAndCleanIp($candidateIp, $allowPrivate);
102 if ($validIp !== '') {
103 return $validIp;
104 }
105 }
106 } else {
107 $validIp = self::validateAndCleanIp($ip, $allowPrivate);
108 if ($validIp !== '') {
109 return $validIp;
110 }
111 }
112 }
113
114 return '';
115 }
116
117 /**
118 * Get the most reliable IP address (prefers direct connection)
119 *
120 * @param bool $allowPrivate Whether to allow private IP addresses
121 * @return string The most reliable IP address
122 */
123 public static function getReliableIpAddress(bool $allowPrivate = false): string
124 {
125 // First try direct connection (most reliable)
126 $directIp = self::getServerVariable('REMOTE_ADDR');
127 if (!empty($directIp)) {
128 $validIp = self::validateAndCleanIp($directIp, $allowPrivate);
129 if ($validIp !== '') {
130 return $validIp;
131 }
132 }
133
134 // Fallback to full detection
135 return self::getUserIpAddress($allowPrivate, true);
136 }
137
138 /**
139 * Check if the current request is from Cloudflare
140 *
141 * @return bool True if request appears to be from Cloudflare
142 */
143 public static function isCloudflareRequest(): bool
144 {
145 return self::isCloudflare();
146 }
147
148 /**
149 * Get detailed IP information for debugging
150 *
151 * @return array<string, mixed> Array containing all IP detection details
152 */
153 public static function getIpDebugInfo(): array
154 {
155 $debugInfo = [
156 'detected_ip' => self::getUserIpAddress(),
157 'reliable_ip' => self::getReliableIpAddress(),
158 'is_cloudflare' => self::isCloudflare(),
159 'cloudflare_ip' => self::getCloudflareIp(),
160 'sources' => []
161 ];
162
163 foreach (self::IP_SOURCES as $source) {
164 $value = self::getServerVariable($source);
165 if (!empty($value)) {
166 $debugInfo['sources'][$source] = $value;
167 }
168 }
169
170 return $debugInfo;
171 }
172
173 /**
174 * Get Cloudflare IP if request is from Cloudflare
175 *
176 * @return string|null The Cloudflare connecting IP or null if not valid
177 */
178 private static function getCloudflareIp(): ?string
179 {
180 if (!self::isCloudflare()) {
181 return null;
182 }
183
184 $cfIP = self::getServerVariable('HTTP_CF_CONNECTING_IP');
185 if (empty($cfIP)) {
186 return null;
187 }
188
189 // Sanitize and validate the IP
190 $sanitizedIp = sanitize_text_field(wp_unslash($cfIP));
191 return filter_var($sanitizedIp, FILTER_VALIDATE_IP) ? $sanitizedIp : null;
192 }
193
194 /**
195 * Check if the request is from Cloudflare
196 *
197 * @return bool True if request is from Cloudflare
198 */
199 private static function isCloudflare(): bool
200 {
201 // Check required Cloudflare headers first
202 foreach (self::CLOUDFLARE_HEADERS as $header) {
203 if (!isset($_SERVER[$header])) {
204 return false;
205 }
206 }
207
208 // Get the source IP for validation
209 $sourceIp = self::getSourceIpForValidation();
210 if (empty($sourceIp)) {
211 return false;
212 }
213
214 // Validate if the source IP is from Cloudflare's ranges
215 return self::isCloudflareIp($sourceIp);
216 }
217
218 /**
219 * Get source IP for Cloudflare validation
220 *
221 * @return string The source IP address
222 */
223 private static function getSourceIpForValidation(): string
224 {
225 $sources = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'];
226
227 foreach ($sources as $source) {
228 $ip = self::getServerVariable($source);
229 if (!empty($ip)) {
230 return sanitize_text_field(wp_unslash($ip));
231 }
232 }
233
234 return '';
235 }
236
237 /**
238 * Validate if IP is from Cloudflare's IP ranges
239 *
240 * @param string $ip The IP address to validate
241 * @return bool True if IP is from Cloudflare
242 */
243 private static function isCloudflareIp(string $ip): bool
244 {
245 foreach (self::CLOUDFLARE_IP_RANGES as $range) {
246 if (self::ipInRange($ip, $range)) {
247 return true;
248 }
249 }
250
251 return false;
252 }
253
254 /**
255 * Validate and clean an IP address
256 *
257 * @param string $ip The IP address to validate
258 * @param bool $allowPrivate Whether to allow private IP addresses
259 * @return string The cleaned IP address or empty string if invalid
260 */
261 private static function validateAndCleanIp(string $ip, bool $allowPrivate): string
262 {
263 // Remove any surrounding whitespace
264 $ip = trim($ip);
265
266 // Remove port number if present (e.g., "192.168.1.1:8080")
267 if (strpos($ip, ':') !== false && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
268 $ip = explode(':', $ip)[0];
269 }
270
271 // Validate IP format
272 if (!filter_var($ip, FILTER_VALIDATE_IP)) {
273 return '';
274 }
275
276 // Check for private IP addresses if not allowed
277 if (!$allowPrivate && self::isPrivateIp($ip)) {
278 return '';
279 }
280
281 return $ip;
282 }
283
284 /**
285 * Check if an IP address is private/local
286 *
287 * @param string $ip The IP address to check
288 * @return bool True if the IP is private
289 */
290 private static function isPrivateIp(string $ip): bool
291 {
292 // Use filter_var for private IP detection
293 return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false;
294 }
295
296 /**
297 * Check if an IP address is within a given CIDR range
298 *
299 * @param string $ip The IP address to check
300 * @param string $range The IP range in CIDR notation
301 * @return bool True if the IP is in the range
302 */
303 private static function ipInRange(string $ip, string $range): bool
304 {
305 if (strpos($range, '/') === false) {
306 $range .= '/32';
307 }
308
309 [$subnet, $netmask] = explode('/', $range, 2);
310 $rangeDecimal = ip2long($subnet);
311 $ipDecimal = ip2long($ip);
312 $wildcardDecimal = pow(2, (32 - (int)$netmask)) - 1;
313 $netmaskDecimal = ~$wildcardDecimal;
314
315 return ($ipDecimal & $netmaskDecimal) === ($rangeDecimal & $netmaskDecimal);
316 }
317
318 /**
319 * Safely get server variable
320 *
321 * @param string $key The server variable key
322 * @return string The server variable value or empty string
323 */
324 private static function getServerVariable(string $key): string
325 {
326 return $_SERVER[$key] ?? '';
327 }
328 }
329