$payload */ public function __construct( public readonly Webhook $webhook, public readonly string $event, public readonly array $payload, ) {} public function handle(): void { $body = json_encode([ 'event' => $this->event, 'payload' => $this->payload, 'timestamp' => now()->toISOString(), ], JSON_THROW_ON_ERROR); $signature = 'sha256='.hash_hmac('sha256', $body, $this->webhook->secret); $host = parse_url($this->webhook->url, PHP_URL_HOST); if ($this->isPrivateHost($host)) { return; } $decodedPayload = json_decode($body, true, 512, JSON_THROW_ON_ERROR); $response = Http::timeout(10) ->withHeaders([ 'Content-Type' => 'application/json', 'X-Nimuli-Signature' => $signature, 'X-Nimuli-Event' => $this->event, ]) ->withBody($body, 'application/json') ->post($this->webhook->url); WebhookDelivery::create([ 'webhook_id' => $this->webhook->id, 'event' => $this->event, 'payload' => $decodedPayload, 'status' => $response->successful() ? 'success' : 'failed', 'response_code' => $response->status(), 'attempts' => max(1, $this->attempts()), ]); } private function isPrivateHost(?string $host): bool { if (! $host) { return true; } // Strip IPv6 brackets: [::1] → ::1 $host = ltrim(rtrim($host, ']'), '['); // If it's already an IP, check it directly (bypasses DNS resolution) if (filter_var($host, FILTER_VALIDATE_IP)) { return ! filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) || $this->isPrivateIPv6($host); } // Resolve hostname to IPv4 $ip = gethostbyname($host); if ($ip === $host) { // Could not resolve or is an IPv6 hostname that gethostbyname can't handle — block return true; } return ! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); } private function isPrivateIPv6(string $ip): bool { if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { return false; } // Block loopback, link-local, unique local, unspecified $privateRanges = ['::1', 'fe80::', 'fc00::', 'fd00::', '::']; foreach ($privateRanges as $range) { if (str_starts_with($ip, $range)) { return true; } } return false; } }