"::1") before IP checks so bracketed // literals can't sneak past filter_var FILTER_VALIDATE_IP. $hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']')) ? substr($host, 1, -1) : $host; // Block well-known dangerous hostnames $blockedHosts = ['localhost', '0.0.0.0', '::1']; if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) { Log::warning('Webhook URL points to blocked host', [ 'attribute' => $attribute, 'host' => $host, 'ip' => request()->ip(), 'user_id' => auth()->id(), ]); $fail('The :attribute must not point to localhost or internal hosts.'); return; } // Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) { Log::warning('Webhook URL points to blocked IP range', [ 'attribute' => $attribute, 'host' => $host, 'ip' => request()->ip(), 'user_id' => auth()->id(), ]); $fail('The :attribute must not point to loopback or link-local addresses.'); return; } } private function isLoopback(string $ip): bool { // 127.0.0.0/8, 0.0.0.0 if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { return true; } // IPv6 loopback $normalized = @inet_pton($ip); return $normalized !== false && $normalized === inet_pton('::1'); } private function isLinkLocal(string $ip): bool { // 169.254.0.0/16 — covers cloud metadata at 169.254.169.254 if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return false; } $long = ip2long($ip); return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16); } }