mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Git source HTTP clients previously disabled redirects entirely, so API
calls for renamed repositories (e.g. GitHub 301 to /repositories/{id})
failed. Add SafeExternalUrl::sameOriginRedirectOptions() and use it in
the GitSource HTTP macro to follow up to five strict redirects that stay
on the same scheme, host and port.
A redirect to a different origin gets a RequestException, because the
DNS pin covers only the original host and port. This blocks SSRF via
redirects to metadata or attacker hosts. Authorization headers and the
original request method and body are kept on same-origin redirects.
Tests cover following same-host redirects, keeping POST on a redirect,
refusing cross-host/scheme/port redirects and the five-redirect cap.
741 lines
24 KiB
PHP
741 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\DNSQuery;
|
|
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 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 [];
|
|
}
|
|
|
|
// The MinIO client keeps one --resolve IP per host and parses it with Go's
|
|
// netip.ParseAddr, which rejects bracketed IPv6. Emit a single entry, preferring
|
|
// IPv4 for reachability, without brackets.
|
|
$preferredIps = array_values(array_filter(
|
|
$target['ips'],
|
|
fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false,
|
|
));
|
|
|
|
$ip = $preferredIps[0] ?? $target['ips'][0];
|
|
|
|
return [sprintf('%s:%d=%s', $target['host'], $target['port'], $ip)];
|
|
}
|
|
|
|
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 {
|
|
$query = new DNSQuery($dnsServer, 53, 5);
|
|
$records = $query->query($host, $type);
|
|
|
|
if ($records === false || $query->hasError()) {
|
|
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 => trim($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(),
|
|
]);
|
|
}
|
|
}
|