feat(webhooks): outgoing webhooks with HMAC signature, SSRF protection, retry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
69ca1ab058
commit
c8a6325a77
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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=');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue