ip_restriction_enabled) { return true; } $whitelist = $apiKey->ip_whitelist ?? []; // Empty whitelist with restrictions enabled = deny all if (empty($whitelist)) { return false; } return $this->isIpInWhitelist($requestIp, $whitelist); } /** * Check if an IP address is in a whitelist. * * Supports: * - Individual IPv4 addresses (192.168.1.1) * - Individual IPv6 addresses (::1, 2001:db8::1) * - CIDR notation for IPv4 (192.168.1.0/24) * - CIDR notation for IPv6 (2001:db8::/32) * * @param array $whitelist */ public function isIpInWhitelist(string $ip, array $whitelist): bool { $ip = trim($ip); // Validate the request IP is a valid IP address if (! filter_var($ip, FILTER_VALIDATE_IP)) { return false; } foreach ($whitelist as $entry) { $entry = trim($entry); if (empty($entry)) { continue; } // Check for CIDR notation if (str_contains($entry, '/')) { if ($this->ipMatchesCidr($ip, $entry)) { return true; } } else { // Exact IP match (normalise both for comparison) if ($this->normaliseIp($ip) === $this->normaliseIp($entry)) { return true; } } } return false; } /** * Check if an IP matches a CIDR range. */ public function ipMatchesCidr(string $ip, string $cidr): bool { $parts = explode('/', $cidr, 2); if (count($parts) !== 2) { return false; } [$range, $prefix] = $parts; $prefix = (int) $prefix; // Validate both IPs if (! filter_var($ip, FILTER_VALIDATE_IP)) { return false; } if (! filter_var($range, FILTER_VALIDATE_IP)) { return false; } $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); $isRangeIpv6 = filter_var($range, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); // IP version must match if ($isIpv6 !== $isRangeIpv6) { return false; } if ($isIpv6) { return $this->ipv6MatchesCidr($ip, $range, $prefix); } return $this->ipv4MatchesCidr($ip, $range, $prefix); } /** * Check if an IPv4 address matches a CIDR range. */ protected function ipv4MatchesCidr(string $ip, string $range, int $prefix): bool { // Validate prefix length if ($prefix < 0 || $prefix > 32) { return false; } $ipLong = ip2long($ip); $rangeLong = ip2long($range); if ($ipLong === false || $rangeLong === false) { return false; } // Create the subnet mask $mask = -1 << (32 - $prefix); // Apply mask and compare return ($ipLong & $mask) === ($rangeLong & $mask); } /** * Check if an IPv6 address matches a CIDR range. */ protected function ipv6MatchesCidr(string $ip, string $range, int $prefix): bool { // Validate prefix length if ($prefix < 0 || $prefix > 128) { return false; } // Convert to binary representation $ipBin = $this->ipv6ToBinary($ip); $rangeBin = $this->ipv6ToBinary($range); if ($ipBin === null || $rangeBin === null) { return false; } // Compare the first $prefix bits $prefixBytes = (int) floor($prefix / 8); $remainingBits = $prefix % 8; // Compare full bytes if (substr($ipBin, 0, $prefixBytes) !== substr($rangeBin, 0, $prefixBytes)) { return false; } // Compare remaining bits if any if ($remainingBits > 0) { $mask = 0xFF << (8 - $remainingBits); $ipByte = ord($ipBin[$prefixBytes]); $rangeByte = ord($rangeBin[$prefixBytes]); if (($ipByte & $mask) !== ($rangeByte & $mask)) { return false; } } return true; } /** * Convert an IPv6 address to its binary representation. */ protected function ipv6ToBinary(string $ip): ?string { $packed = inet_pton($ip); if ($packed === false) { return null; } return $packed; } /** * Normalise an IP address for comparison. * * - IPv4: No change needed * - IPv6: Expand to full form for consistent comparison */ public function normaliseIp(string $ip): string { $ip = trim($ip); // Try to pack and unpack for normalisation $packed = inet_pton($ip); if ($packed === false) { return $ip; // Return original if invalid } // inet_ntop will return normalised form $normalised = inet_ntop($packed); return $normalised !== false ? $normalised : $ip; } /** * Validate an IP address or CIDR notation. * * @return array{valid: bool, error: ?string} */ public function validateEntry(string $entry): array { $entry = trim($entry); if (empty($entry)) { return ['valid' => false, 'error' => 'Empty entry']; } // Check for CIDR notation if (str_contains($entry, '/')) { return $this->validateCidr($entry); } // Validate as plain IP if (! filter_var($entry, FILTER_VALIDATE_IP)) { return ['valid' => false, 'error' => 'Invalid IP address']; } return ['valid' => true, 'error' => null]; } /** * Validate CIDR notation. * * @return array{valid: bool, error: ?string} */ public function validateCidr(string $cidr): array { $parts = explode('/', $cidr, 2); if (count($parts) !== 2) { return ['valid' => false, 'error' => 'Invalid CIDR notation']; } [$ip, $prefix] = $parts; // Validate IP portion if (! filter_var($ip, FILTER_VALIDATE_IP)) { return ['valid' => false, 'error' => 'Invalid IP address in CIDR']; } // Validate prefix is numeric if (! is_numeric($prefix)) { return ['valid' => false, 'error' => 'Invalid prefix length']; } $prefix = (int) $prefix; $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); // Validate prefix range if ($isIpv6) { if ($prefix < 0 || $prefix > 128) { return ['valid' => false, 'error' => 'IPv6 prefix must be between 0 and 128']; } } else { if ($prefix < 0 || $prefix > 32) { return ['valid' => false, 'error' => 'IPv4 prefix must be between 0 and 32']; } } return ['valid' => true, 'error' => null]; } /** * Parse a multi-line string of IPs/CIDRs into an array. * * @return array{entries: array, errors: array} */ public function parseWhitelistInput(string $input): array { $lines = preg_split('/[\r\n,]+/', $input); $entries = []; $errors = []; foreach ($lines as $line) { $line = trim($line); if (empty($line)) { continue; } // Skip comments if (str_starts_with($line, '#')) { continue; } $validation = $this->validateEntry($line); if ($validation['valid']) { $entries[] = $line; } else { $errors[] = "{$line}: {$validation['error']}"; } } return [ 'entries' => $entries, 'errors' => $errors, ]; } /** * Format a whitelist array as a multi-line string. * * @param array $whitelist */ public function formatWhitelistForDisplay(array $whitelist): string { return implode("\n", $whitelist); } /** * Get a human-readable description of a CIDR range. */ public function describeCidr(string $cidr): string { $parts = explode('/', $cidr, 2); if (count($parts) !== 2) { return $cidr; } [$ip, $prefix] = $parts; $prefix = (int) $prefix; $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); if ($isIpv6) { $totalHosts = bcpow('2', (string) (128 - $prefix)); return "{$cidr} ({$totalHosts} addresses)"; } $totalHosts = 2 ** (32 - $prefix); return "{$cidr} ({$totalHosts} addresses)"; } }