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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue