Retry delays in seconds per attempt. */ public array $backoff = [60, 300, 1800, 7200, 28800]; public int $timeout = 30; public int $tries = 5; public function __construct(public readonly string $deliveryId) {} public function handle(): void { /** @var FormWebhookDelivery|null $delivery */ $delivery = FormWebhookDelivery::query()->find($this->deliveryId); if ($delivery === null) { return; } $webhook = $delivery->webhook; if ($webhook === null || ! $webhook->is_active) { $delivery->status = FormWebhookDeliveryStatus::FAILED->value; $delivery->failed_permanently_at = now(); $delivery->save(); return; } $url = (string) $webhook->url; if (! $this->urlIsSafe($url)) { $delivery->status = FormWebhookDeliveryStatus::FAILED->value; $delivery->failed_permanently_at = now(); $delivery->response_body_excerpt = 'SSRF protection: URL rejected.'; $delivery->save(); return; } // RFC-WS-6 session 2.7 — canonical JSON for HMAC signing. // payload_snapshot was read from a MySQL JSON column whose key // order may not match what we wrote. Canonicalize so the // signature is byte-stable across re-deliveries and matches what // a verifying receiver computes from the same logical payload. $payload = (array) ($delivery->payload_snapshot ?? []); $body = JsonCanonicalizer::encode($payload); $headers = ['Content-Type' => 'application/json']; if (! empty($webhook->secret)) { $headers['X-Crewli-Signature'] = 'sha256='.hash_hmac('sha256', $body, (string) $webhook->secret); } $delivery->attempts = (int) $delivery->attempts + 1; $delivery->last_attempt_at = now(); try { $response = Http::withHeaders($headers) ->timeout((int) config('form_builder.webhooks.timeout_seconds', 10)) ->withBody($body, 'application/json') ->post($url); $delivery->response_status = $response->status(); $delivery->response_body_excerpt = mb_substr((string) $response->body(), 0, 1000); if ($response->successful()) { $delivery->status = FormWebhookDeliveryStatus::DELIVERED->value; $delivery->delivered_at = now(); $delivery->save(); return; } if ($this->isRetriable($response->status()) && $delivery->attempts < $this->tries) { $delivery->status = FormWebhookDeliveryStatus::PENDING->value; $delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800); $delivery->save(); $this->release($this->backoff[$delivery->attempts - 1] ?? 28800); return; } $delivery->status = $delivery->attempts >= $this->tries ? FormWebhookDeliveryStatus::DEAD_LETTER->value : FormWebhookDeliveryStatus::FAILED->value; $delivery->failed_permanently_at = now(); $delivery->save(); } catch (\Throwable $e) { Log::warning('form-webhook.delivery.exception', [ 'delivery_id' => $delivery->id, 'message' => $e->getMessage(), ]); if ($delivery->attempts < $this->tries) { $delivery->status = FormWebhookDeliveryStatus::PENDING->value; $delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800); $delivery->save(); $this->release($this->backoff[$delivery->attempts - 1] ?? 28800); return; } $delivery->status = FormWebhookDeliveryStatus::DEAD_LETTER->value; $delivery->failed_permanently_at = now(); $delivery->save(); } } private function urlIsSafe(string $url): bool { $parts = parse_url($url); if ($parts === false || ! isset($parts['host'], $parts['scheme'])) { return false; } if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) { return false; } $host = (string) $parts['host']; $allowlist = (array) config('form_builder.webhooks.allowlist_domains', []); if ($allowlist !== [] && ! in_array(strtolower($host), array_map('strtolower', $allowlist), true)) { return false; } $ips = @gethostbynamel($host) ?: [$host]; $blocklist = (array) config('form_builder.webhooks.blocklist_ips', []); foreach ($ips as $ip) { if (! filter_var($ip, FILTER_VALIDATE_IP)) { continue; } foreach ($blocklist as $cidr) { if ($this->ipInCidr($ip, (string) $cidr)) { return false; } } if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { return false; } } return true; } private function ipInCidr(string $ip, string $cidr): bool { if (! str_contains($cidr, '/')) { return $ip === $cidr; } [$subnet, $mask] = explode('/', $cidr, 2); $ipLong = ip2long($ip); $subnetLong = ip2long($subnet); if ($ipLong === false || $subnetLong === false) { return false; } $maskLong = -1 << (32 - (int) $mask); return ($ipLong & $maskLong) === ($subnetLong & $maskLong); } private function isRetriable(int $status): bool { if ($status >= 500) { return true; } return in_array($status, [408, 425, 429], true); } }