fix(webhooks): HMAC integrity fix (send raw body), IPv6 SSRF protection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-16 07:49:25 +02:00
parent c8a6325a77
commit e33a1fafde
2 changed files with 41 additions and 9 deletions

View File

@ -38,18 +38,21 @@ class DeliverWebhookJob implements ShouldQueue
return; return;
} }
$decodedPayload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$response = Http::timeout(10) $response = Http::timeout(10)
->withHeaders([ ->withHeaders([
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'X-Nimuli-Signature' => $signature, 'X-Nimuli-Signature' => $signature,
'X-Nimuli-Event' => $this->event, 'X-Nimuli-Event' => $this->event,
]) ])
->post($this->webhook->url, json_decode($body, true, 512, JSON_THROW_ON_ERROR)); ->withBody($body, 'application/json')
->post($this->webhook->url);
WebhookDelivery::create([ WebhookDelivery::create([
'webhook_id' => $this->webhook->id, 'webhook_id' => $this->webhook->id,
'event' => $this->event, 'event' => $this->event,
'payload' => json_decode($body, true, 512, JSON_THROW_ON_ERROR), 'payload' => $decodedPayload,
'status' => $response->successful() ? 'success' : 'failed', 'status' => $response->successful() ? 'success' : 'failed',
'response_code' => $response->status(), 'response_code' => $response->status(),
'attempts' => max(1, $this->attempts()), 'attempts' => max(1, $this->attempts()),
@ -62,12 +65,42 @@ class DeliverWebhookJob implements ShouldQueue
return true; return true;
} }
$ip = gethostbyname($host); // Strip IPv6 brackets: [::1] → ::1
$host = ltrim(rtrim($host, ']'), '[');
return filter_var( // If it's already an IP, check it directly (bypasses DNS resolution)
$ip, if (filter_var($host, FILTER_VALIDATE_IP)) {
FILTER_VALIDATE_IP, return ! filter_var(
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE $host,
) === false; 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;
} }
} }

View File

@ -10,7 +10,6 @@ class WebhookDelivery extends Model
protected $casts = [ protected $casts = [
'payload' => 'array', 'payload' => 'array',
'delivered_at' => 'datetime',
]; ];
public function webhook(): \Illuminate\Database\Eloquent\Relations\BelongsTo public function webhook(): \Illuminate\Database\Eloquent\Relations\BelongsTo