nimuli/app/Domains/Webhook/Jobs/DeliverWebhookJob.php

107 lines
3.2 KiB
PHP

<?php
namespace App\Domains\Webhook\Jobs;
use App\Domains\Webhook\Models\Webhook;
use App\Domains\Webhook\Models\WebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Http;
class DeliverWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 5;
public int $backoff = 60;
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;
}
}