Reuse the existing SafeWebhookUrl rule on the S3 Storage endpoint field so the create and edit forms go through the same URL-normalization path as webhook settings. Adds a matching guard inside S3Storage::testConnection() so background callers (scheduled backups, database import reuse) also validate the endpoint before building the S3 client. Also fixes an IPv6-bracket edge case in SafeWebhookUrl so `http://[::1]` style hosts are normalized before the filter_var IP check — the rule's own loopback test was already asserting this behaviour.
101 lines
3.2 KiB
PHP
101 lines
3.2 KiB
PHP
<?php
|
|
|
|
namespace App\Rules;
|
|
|
|
use Closure;
|
|
use Illuminate\Contracts\Validation\ValidationRule;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class SafeWebhookUrl implements ValidationRule
|
|
{
|
|
/**
|
|
* Run the validation rule.
|
|
*
|
|
* Validates that a webhook URL is safe for server-side requests.
|
|
* Blocks loopback addresses, cloud metadata endpoints (link-local),
|
|
* and dangerous hostnames while allowing private network IPs
|
|
* for self-hosted deployments.
|
|
*/
|
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
{
|
|
if (! filter_var($value, FILTER_VALIDATE_URL)) {
|
|
$fail('The :attribute must be a valid URL.');
|
|
|
|
return;
|
|
}
|
|
|
|
$scheme = strtolower(parse_url($value, PHP_URL_SCHEME) ?? '');
|
|
if (! in_array($scheme, ['https', 'http'])) {
|
|
$fail('The :attribute must use the http or https scheme.');
|
|
|
|
return;
|
|
}
|
|
|
|
$host = parse_url($value, PHP_URL_HOST);
|
|
if (! $host) {
|
|
$fail('The :attribute must contain a valid host.');
|
|
|
|
return;
|
|
}
|
|
|
|
$host = strtolower($host);
|
|
|
|
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
|
|
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
|
|
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
|
? substr($host, 1, -1)
|
|
: $host;
|
|
|
|
// Block well-known dangerous hostnames
|
|
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
|
|
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
|
|
Log::warning('Webhook URL points to blocked host', [
|
|
'attribute' => $attribute,
|
|
'host' => $host,
|
|
'ip' => request()->ip(),
|
|
'user_id' => auth()->id(),
|
|
]);
|
|
$fail('The :attribute must not point to localhost or internal hosts.');
|
|
|
|
return;
|
|
}
|
|
|
|
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
|
|
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
|
|
Log::warning('Webhook URL points to blocked IP range', [
|
|
'attribute' => $attribute,
|
|
'host' => $host,
|
|
'ip' => request()->ip(),
|
|
'user_id' => auth()->id(),
|
|
]);
|
|
$fail('The :attribute must not point to loopback or link-local addresses.');
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
private function isLoopback(string $ip): bool
|
|
{
|
|
// 127.0.0.0/8, 0.0.0.0
|
|
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
|
|
return true;
|
|
}
|
|
|
|
// IPv6 loopback
|
|
$normalized = @inet_pton($ip);
|
|
|
|
return $normalized !== false && $normalized === inet_pton('::1');
|
|
}
|
|
|
|
private function isLinkLocal(string $ip): bool
|
|
{
|
|
// 169.254.0.0/16 — covers cloud metadata at 169.254.169.254
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
return false;
|
|
}
|
|
|
|
$long = ip2long($ip);
|
|
|
|
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
|
|
}
|
|
}
|