fix(webhooks): HMAC integrity fix (send raw body), IPv6 SSRF protection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
c8a6325a77
commit
e33a1fafde
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ class WebhookDelivery extends Model
|
|||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'delivered_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function webhook(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
|
|
|
|||
Loading…
Reference in New Issue