mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
mc keeps one --resolve IP per host, and Coolify always pinned the IPv4 address of a dual-stack endpoint. On servers without IPv4, uploads failed with "network is unreachable". A helper container on the upload network now connects to each validated IP, IPv4 first, and the first IP that connects is pinned. S3 restores are now pinned in the same way. Closes #12397 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
739 lines
24 KiB
PHP
739 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Rules;
|
|
|
|
use App\Models\InstanceSettings;
|
|
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 PurplePixie\PhpDns\DNSTypes;
|
|
use Throwable;
|
|
|
|
class SafeWebhookUrl implements ValidationRule
|
|
{
|
|
/**
|
|
* @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.
|
|
*/
|
|
public function __construct(
|
|
private ?Closure $resolver = null,
|
|
private array $trustedInternalHosts = [],
|
|
private bool $allowPrivateNetworks = false,
|
|
) {}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
|
|
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, ['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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private static function isIpv6(string $ip): bool
|
|
{
|
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
}
|
|
|
|
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
|
|
{
|
|
$rule = new self(resolver: $resolver, trustedInternalHosts: $trustedInternalHosts, allowPrivateNetworks: $allowPrivateNetworks);
|
|
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, ['http', 'https'], true)) {
|
|
throw new \RuntimeException('Webhook URL scheme is unsafe.');
|
|
}
|
|
|
|
$port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80);
|
|
$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
|
|
{
|
|
if ($this->resolver instanceof Closure) {
|
|
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
|
|
}
|
|
|
|
if ($host === 'localhost') {
|
|
return ['127.0.0.1', '::1'];
|
|
}
|
|
|
|
$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 ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
|
|
try {
|
|
$records = queryDnsServer($dnsServer, $host, $type);
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|