feat(webhooks): outgoing webhooks with HMAC signature, SSRF protection, retry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 07:46:57 +02:00
parent 69ca1ab058
commit c8a6325a77
6 changed files with 199 additions and 0 deletions

View File

@ -0,0 +1,73 @@
<?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;
}
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'X-Nimuli-Signature' => $signature,
'X-Nimuli-Event' => $this->event,
])
->post($this->webhook->url, json_decode($body, true, 512, JSON_THROW_ON_ERROR));
WebhookDelivery::create([
'webhook_id' => $this->webhook->id,
'event' => $this->event,
'payload' => json_decode($body, true, 512, JSON_THROW_ON_ERROR),
'status' => $response->successful() ? 'success' : 'failed',
'response_code' => $response->status(),
'attempts' => max(1, $this->attempts()),
]);
}
private function isPrivateHost(?string $host): bool
{
if (! $host) {
return true;
}
$ip = gethostbyname($host);
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) === false;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Domains\Webhook\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Webhook extends Model
{
use HasFactory, SoftDeletes;
protected $guarded = ['id'];
protected $casts = [
'events' => 'array',
'is_active' => 'boolean',
];
protected static function newFactory(): \Database\Factories\WebhookFactory
{
return \Database\Factories\WebhookFactory::new();
}
public function deliveries(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(WebhookDelivery::class);
}
public function workspace(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Domains\Workspace\Models\Workspace::class);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domains\Webhook\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookDelivery extends Model
{
protected $guarded = ['id'];
protected $casts = [
'payload' => 'array',
'delivered_at' => 'datetime',
];
public function webhook(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Webhook::class);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Domains\Webhook\Services;
use App\Domains\Webhook\Jobs\DeliverWebhookJob;
use App\Domains\Webhook\Models\Webhook;
class WebhookDispatcher
{
public function dispatch(string $event, array $payload, int $workspaceId): void
{
Webhook::where('workspace_id', $workspaceId)
->where('is_active', true)
->whereJsonContains('events', $event)
->each(function (Webhook $webhook) use ($event, $payload) {
DeliverWebhookJob::dispatch($webhook, $event, $payload)
->onQueue('default');
});
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Database\Factories;
use App\Domains\Webhook\Models\Webhook;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookFactory extends Factory
{
protected $model = Webhook::class;
public function definition(): array
{
$user = User::factory()->create();
$workspace = (new \App\Domains\Workspace\Actions\CreateWorkspace)->handle($user, ['name' => 'Test']);
return [
'workspace_id' => $workspace->id,
'url' => $this->faker->url(),
'secret' => $this->faker->sha256(),
'events' => ['click.recorded'],
'is_active' => true,
];
}
}

View File

@ -0,0 +1,25 @@
<?php
use App\Domains\Webhook\Jobs\DeliverWebhookJob;
use App\Domains\Webhook\Models\Webhook;
use Illuminate\Support\Facades\Http;
it('dispatches webhook with HMAC signature', function () {
Http::fake(['*' => Http::response('ok', 200)]);
$webhook = Webhook::factory()->create([
'url' => 'https://example.com/hook',
'secret' => 'mysecret',
'events' => ['click.recorded'],
'is_active' => true,
]);
// Run the job synchronously to test HTTP call
$job = new DeliverWebhookJob($webhook, 'click.recorded', ['link_id' => 1]);
$job->handle();
Http::assertSent(function ($request) {
return $request->hasHeader('X-Nimuli-Signature')
&& str_starts_with($request->header('X-Nimuli-Signature')[0], 'sha256=');
});
});