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;
}
$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,
])
->post($this->webhook->url, json_decode($body, true, 512, JSON_THROW_ON_ERROR));
->withBody($body, 'application/json')
->post($this->webhook->url);
WebhookDelivery::create([
'webhook_id' => $this->webhook->id,
'event' => $this->event,
'payload' => json_decode($body, true, 512, JSON_THROW_ON_ERROR),
'payload' => $decodedPayload,
'status' => $response->successful() ? 'success' : 'failed',
'response_code' => $response->status(),
'attempts' => max(1, $this->attempts()),
@ -62,12 +65,42 @@ class DeliverWebhookJob implements ShouldQueue
return true;
}
$ip = gethostbyname($host);
// Strip IPv6 brackets: [::1] → ::1
$host = ltrim(rtrim($host, ']'), '[');
return filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) === false;
// 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;
}
}

View File

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