app/Rules/SafeWebhookUrl.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace App\Rules;
use App\Models\InstanceSettings;
use App\Services\Dns\DnsResolver;
use Closure;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\UriComparator;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Throwable;
class SafeWebhookUrl implements ValidationRule
{
private const DEFAULT_PORTS = ['https' => 443, 'http' => 80, 'sftp' => 22, 'ftps' => 21];
/** @var (Closure(string): array<int, string>)|null */
private static ?Closure $fakeResolver = null;
/**
* @param (Closure(string): array<int, string>)|null $resolver
* @param array<int, string> $trustedInternalHosts
* @param bool $allowPrivateNetworks Allow private, CGNAT, and unique local addresses and internal hostnames.
* Loopback, link-local (cloud metadata), and reserved targets stay blocked.
* @param array<int, string> $schemes The URL schemes to accept, for example sftp for a backup storage.
* @param bool $preferSystemDns Ask the system resolver before the custom DNS servers.
* @param int $customDnsTimeout Seconds to wait for each custom DNS server query.
*/
public function __construct(
private ?Closure $resolver = null,
private array $trustedInternalHosts = [],
private bool $allowPrivateNetworks = false,
private array $schemes = ['http', 'https'],
private bool $preferSystemDns = false,
private int $customDnsTimeout = 5,
) {}
/**
* Git sources such as GitHub Enterprise or GitLab often run on a private network.
* Self-hosted instances allow them. Coolify Cloud keeps them blocked.
*/
public static function forGitSource(): static
{
return new static(allowPrivateNetworks: self::gitSourcesMayUsePrivateNetworks());
}
/**
* SMTP hosts get the targets of git sources. They are often internal names (split-horizon DNS,
* Docker networks), so the system resolver answers first, and a send does not wait long for
* custom DNS servers.
*/
public static function forSmtpHost(): static
{
return new static(
allowPrivateNetworks: self::gitSourcesMayUsePrivateNetworks(),
preferSystemDns: true,
customDnsTimeout: 2,
);
}
/**
* Resolve hosts with the given callback instead of DNS in every rule without its own
* resolver, so tests never send DNS queries. Pass null to reset.
*
* @param (Closure(string): array<int, string>)|null $resolver
*/
public static function resolveHostsUsing(?Closure $resolver): void
{
self::$fakeResolver = $resolver;
}
public static function gitSourcesMayUsePrivateNetworks(): bool
{
return ! isCloud();
}
/**
* Run the validation rule.
*
* Validates that a webhook URL is safe for server-side requests.
* Blocks loopback addresses, cloud metadata endpoints (link-local),
* private/reserved ranges, and dangerous hostnames unless the
* instance operator explicitly allowlists the intranet target.
*/
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, $this->schemes, true)) {
$fail('The :attribute must use the '.implode(' or ', $this->schemes).' scheme.');
return;
}
$host = parse_url($value, PHP_URL_HOST);
if (! $host) {
$fail('The :attribute must contain a valid host.');
return;
}
if (str_ends_with($host, '.')) {
$fail('The :attribute host must not end with a trailing dot.');
return;
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
$this->logBlockedHost($attribute, $host);
$fail($this->privateTargetMessage($attribute));
return;
}
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail($this->isLinkLocalIp($hostForIpCheck)
? 'The :attribute must not point to link-local addresses.'
: $this->privateTargetMessage($attribute));
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail($this->isLinkLocalIp($resolvedIp)
? 'The :attribute must not resolve to a link-local address.'
: $this->privateTargetMessage($attribute));
return;
}
}
}
private function privateTargetMessage(string $attribute): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
return "The {$attribute} points to a local or private address that is not allowed. Configure allowed internal targets: {$settingsUrl}";
}
/**
* Build HTTP client options that pin the validated host to the resolved IPs.
*
* @param (Closure(string): array<int, string>)|null $resolver
* @return array<string, mixed>
*/
public static function httpClientOptions(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false): array
{
$options = ['allow_redirects' => false];
if (! defined('CURLOPT_RESOLVE')) {
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
}
$target = self::resolveUrlForRequest($url, $trustedInternalHosts, $resolver, $allowPrivateNetworks);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return $options;
}
// libcurl keeps only the last CURLOPT_RESOLVE entry for a given
// host:port pair, so every resolved address must be pinned in a single
// comma-separated entry. Emitting one entry per address silently drops
// all but the last, which is an IPv6 address whenever the host has AAAA
// records -- and that fails instantly on hosts without IPv6 egress.
$addresses = implode(',', array_map(
fn (string $ip): string => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip,
$target['ips'],
));
$options['curl'] = [
CURLOPT_RESOLVE => [
sprintf('%s:%d:%s', $target['host'], $target['port'], $addresses),
],
];
return $options;
}
/**
* Redirect options that follow up to five redirects on the same scheme, host and port.
* The DNS pin from httpClientOptions() covers only that host and port, so a redirect
* to another origin is refused. Git providers use such redirects for renamed repositories.
*
* @return array<string, mixed>
*/
public static function sameOriginRedirectOptions(): array
{
return [
'max' => 5,
'strict' => true,
'referer' => false,
'protocols' => ['http', 'https'],
'on_redirect' => function (RequestInterface $request, ResponseInterface $response, UriInterface $target): void {
if (UriComparator::isCrossOrigin($request->getUri(), $target)) {
throw new RequestException(
"Refused a redirect to another host ({$target->getScheme()}://{$target->getAuthority()}).",
$request,
$response,
);
}
},
];
}
/**
* Build mc --resolve mappings, IPv4 first, that pin the endpoint host for S3 backups.
*
* @return array<int, string>
*/
public static function minioClientResolveOptions(string $url, array $trustedInternalHosts = []): array
{
$target = self::resolveUrlForRequest($url, $trustedInternalHosts);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return [];
}
// mc parses --resolve values with Go's netip.ParseAddr, which rejects bracketed IPv6.
// mc also keeps one IP per host, so S3Storage::minioClientResolveOptions() picks one entry.
$ips = $target['ips'];
usort($ips, fn (string $a, string $b): int => self::isIpv6($a) <=> self::isIpv6($b));
return array_map(fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], $ip), $ips);
}
/**
* The address to connect to for an endpoint that is not HTTP (SFTP, FTPS): the validated IP of its host, so a
* DNS change after the check cannot send the connection to another address.
*
* @param array<int, string> $schemes
* @param array<int, string> $trustedInternalHosts
*/
public static function pinnedAddress(string $url, array $schemes, array $trustedInternalHosts = []): string
{
$target = self::resolveUrlForRequest($url, $trustedInternalHosts, schemes: $schemes);
return $target['ips'] === [] ? $target['host'] : self::preferredIp($target['ips']);
}
/**
* @param non-empty-array<int, string> $ips
*/
private static function preferredIp(array $ips): string
{
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
return $ip;
}
}
return $ips[0];
}
private static function isIpv6(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
/**
* Resolve and check a host for a connection that is not HTTP (for example SMTP).
* The caller checks the host syntax first. "ips" has the checked addresses, or is empty
* when the host is an IP address.
*
* @return array{status: 'allowed'|'blocked'|'link_local'|'unresolved', ips: array<int, string>}
*/
public function checkConnectionHost(string $host): array
{
$host = rtrim($this->normalizeHostForIpCheck(strtolower($host)), '.');
if ($this->isBlockedHostname($host) && ! $this->isAllowedHostname($host)) {
$this->logBlockedHost('connection host', $host);
return ['status' => 'blocked', 'ips' => []];
}
$isIp = filter_var($host, FILTER_VALIDATE_IP) !== false;
$ips = $isIp ? [$host] : $this->resolveHost($host);
if ($ips === []) {
return ['status' => 'unresolved', 'ips' => []];
}
foreach ($ips as $ip) {
if (! $this->isAllowedIp($ip, $host)) {
$this->logBlockedIp('connection host', $host, $ip);
return ['status' => $this->isLinkLocalIp($ip) ? 'link_local' : 'blocked', 'ips' => []];
}
}
return ['status' => 'allowed', 'ips' => $isIp ? [] : $ips];
}
public static function redactedUrlForLog(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || ! is_string($host)) {
return '[invalid-url]';
}
return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : '');
}
/**
* @return array{host: string, port: int, ips: array<int, string>}
*/
private static function resolveUrlForRequest(string $url, array $trustedInternalHosts = [], ?Closure $resolver = null, bool $allowPrivateNetworks = false, array $schemes = ['http', 'https']): array
{
$rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts, allowPrivateNetworks: $allowPrivateNetworks, schemes: $schemes);
if (! filter_var($url, FILTER_VALIDATE_URL)) {
throw new \RuntimeException('Webhook URL is invalid.');
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
if (str_ends_with($host, '.')) {
throw new \RuntimeException('Webhook URL host must not end with a trailing dot.');
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
if (! in_array($scheme, $schemes, true)) {
throw new \RuntimeException('Webhook URL scheme is unsafe.');
}
$port = parse_url($url, PHP_URL_PORT) ?: self::DEFAULT_PORTS[$scheme];
$hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.');
if (filter_var($hostForDns, FILTER_VALIDATE_IP)) {
if (! $rule->isAllowedIp($hostForDns, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => []];
}
$resolvedIps = $rule->resolveHost($hostForDns);
if ($resolvedIps === []) {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
foreach ($resolvedIps as $resolvedIp) {
if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
}
if ($rule->isBlockedHostname($hostForDns) && ! $rule->isAllowedHostname($hostForDns)) {
throw new \RuntimeException('Webhook URL host is unsafe.');
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps];
}
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
$resolver = $this->resolver ?? self::$fakeResolver;
if ($resolver instanceof Closure) {
return array_values(array_filter($resolver($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
if ($host === 'localhost') {
return ['127.0.0.1', '::1'];
}
if ($this->preferSystemDns) {
$systemResolvedIps = $this->resolveHostWithSystemDns($host);
$customDnsServers = $this->customDnsServers();
return $systemResolvedIps !== [] || $customDnsServers === []
? $systemResolvedIps
: $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
}
$customDnsServers = $this->customDnsServers();
if ($customDnsServers !== []) {
$customResolvedIps = $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
// Fall back to the system resolver when custom DNS has no answer so
// docker/internal hostnames (e.g. coolify-minio) still work with an
// instance-level public DNS server configured.
if ($customResolvedIps !== []) {
return $customResolvedIps;
}
}
return $this->resolveHostWithSystemDns($host);
}
/**
* @return array<int, string>
*/
protected function resolveHostWithSystemDns(string $host): array
{
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
/**
* @param array<int, string> $dnsServers
* @return array<int, string>
*/
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
{
$ips = [];
foreach ($dnsServers as $dnsServer) {
foreach ([DnsResolver::TYPE_A, DnsResolver::TYPE_AAAA] as $type) {
try {
$records = queryDnsServer($dnsServer, $host, $type, $this->customDnsTimeout);
if ($records === false) {
continue;
}
foreach ($records as $record) {
if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) {
$ips[] = $record->getData();
}
}
} catch (Throwable) {
continue;
}
}
}
return array_values(array_unique($ips));
}
/**
* @return array<int, string>
*/
private function customDnsServers(): array
{
$servers = $this->instanceSettings()?->custom_dns_servers ?? '';
if (! is_string($servers)) {
return [];
}
return array_values(array_filter(array_map(
fn (string $server): string => normalizeIpAddress($server),
explode(',', $servers),
), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false));
}
private function isAllowedIp(string $ip, string $host): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
$ip = $embeddedIpv4;
}
if ($this->isLinkLocalIp($ip)) {
return false;
}
if ($this->isPublicIp($ip)) {
return true;
}
if ($this->isLocalhostIp($ip)) {
return $this->allowLocalhost()
&& ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip));
}
if ($this->isPrivateIp($ip)) {
return $this->allowPrivateNetworks || $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
}
if ($this->allowPrivateNetworks && $this->ipv4InCidr($ip, '100.64.0.0/10')) {
return true;
}
return $this->isAllowlistedIp($ip);
}
private function isLinkLocalIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, '169.254.0.0/16');
}
return $this->ipInCidr($ip, 'fe80::/10');
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIpv4($embeddedIpv4);
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIp($ip);
}
private function isLocalhostIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isLocalhostIp($embeddedIpv4);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, '127.0.0.0/8');
}
return @inet_pton($ip) === @inet_pton('::1');
}
private function isPrivateIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
$ip = $embeddedIpv4;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false
&& filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
private function isSpecialUseIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isSpecialUseIpv4($ip);
}
return $this->isSpecialUseIpv6($ip);
}
private function isSpecialUseIpv4(string $ip): bool
{
foreach ([
'0.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'192.0.0.0/24',
'192.0.2.0/24',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
'255.255.255.255/32',
] as $cidr) {
if ($this->ipv4InCidr($ip, $cidr)) {
return true;
}
}
return false;
}
private function isSpecialUseIpv6(string $ip): bool
{
$ipBytes = @inet_pton($ip);
if ($ipBytes === false) {
return false;
}
foreach ([
'::/128',
'::1/128',
'::ffff:0:0/96',
'64:ff9b::/96',
'64:ff9b:1::/48',
'100::/64',
'2001::/23',
'2001:2::/48',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'ff00::/8',
] as $cidr) {
[$network, $prefix] = explode('/', $cidr, 2);
$networkBytes = @inet_pton($network);
if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) {
return true;
}
}
return false;
}
private function isBlockedHostname(string $host): bool
{
if ($host === 'localhost') {
return true;
}
// The resolved addresses of internal hostnames are still checked.
if ($this->allowPrivateNetworks) {
return false;
}
return str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')
|| str_ends_with($host, '.cluster.local');
}
private function isAllowedHostname(string $host): bool
{
if (in_array($host, array_map('strtolower', $this->trustedInternalHosts), true)) {
return true;
}
foreach ($this->allowlistEntries() as $entry) {
if (! str_contains($entry, '/') && strtolower($entry) === $host) {
return true;
}
}
return false;
}
private function isAllowlistedIp(string $ip): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (str_contains($entry, '/')) {
if ($this->ipInCidr($ip, $entry)) {
return true;
}
continue;
}
if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) {
return true;
}
}
return false;
}
/**
* @return array<int, string>
*/
private function allowlistEntries(): array
{
$entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? [];
if (is_string($entries)) {
$entries = explode(',', $entries);
}
if (! is_array($entries)) {
return [];
}
return array_values(array_filter(array_map(
fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'),
$entries,
)));
}
private function allowLocalhost(): bool
{
return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false);
}
private function instanceSettings(): ?InstanceSettings
{
try {
return InstanceSettings::query()->find(0);
} catch (Throwable) {
return null;
}
}
private function ipInCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, $cidr);
}
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 128) {
return false;
}
$ipBytes = @inet_pton($ip);
$networkBytes = @inet_pton($network);
if ($ipBytes === false || $networkBytes === false) {
return false;
}
return $this->binaryInCidr($ipBytes, $networkBytes, $prefix);
}
private function ipv4InCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 32) {
return false;
}
$ipLong = ip2long($ip);
$networkLong = ip2long($network);
if ($ipLong === false || $networkLong === false) {
return false;
}
$mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix));
return ($ipLong & $mask) === ($networkLong & $mask);
}
private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool
{
$bytes = intdiv($prefix, 8);
$bits = $prefix % 8;
if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) {
return false;
}
if ($bits === 0) {
return true;
}
$mask = 0xFF << (8 - $bits) & 0xFF;
return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $host): void
{
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $host, string $blockedIp): void
{
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'resolved_ip' => $blockedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
}