2026-03-28 11:22:59 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Rules;
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
use App\Models\InstanceSettings;
|
2026-03-28 11:22:59 +00:00
|
|
|
use Closure;
|
|
|
|
|
use Illuminate\Contracts\Validation\ValidationRule;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
2026-07-02 14:47:55 +00:00
|
|
|
use PurplePixie\PhpDns\DNSQuery;
|
|
|
|
|
use PurplePixie\PhpDns\DNSTypes;
|
2026-07-02 14:35:39 +00:00
|
|
|
use Throwable;
|
2026-03-28 11:22:59 +00:00
|
|
|
|
|
|
|
|
class SafeWebhookUrl implements ValidationRule
|
|
|
|
|
{
|
2026-07-02 12:46:46 +00:00
|
|
|
/**
|
|
|
|
|
* @param (Closure(string): array<int, string>)|null $resolver
|
|
|
|
|
*/
|
|
|
|
|
public function __construct(private ?Closure $resolver = null) {}
|
|
|
|
|
|
2026-03-28 11:22:59 +00:00
|
|
|
/**
|
|
|
|
|
* Run the validation rule.
|
|
|
|
|
*
|
|
|
|
|
* Validates that a webhook URL is safe for server-side requests.
|
|
|
|
|
* Blocks loopback addresses, cloud metadata endpoints (link-local),
|
2026-07-02 14:35:39 +00:00
|
|
|
* private/reserved ranges, and dangerous hostnames unless the
|
|
|
|
|
* instance operator explicitly allowlists the intranet target.
|
2026-03-28 11:22:59 +00:00
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
if (str_ends_with($host, '.')) {
|
|
|
|
|
$fail('The :attribute host must not end with a trailing dot.');
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 11:22:59 +00:00
|
|
|
$host = strtolower($host);
|
2026-07-02 12:46:46 +00:00
|
|
|
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
|
|
|
|
$hostForDns = rtrim($hostForIpCheck, '.');
|
2026-03-28 11:22:59 +00:00
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
|
2026-07-02 12:46:46 +00:00
|
|
|
$this->logBlockedHost($attribute, $host);
|
2026-03-28 11:22:59 +00:00
|
|
|
$fail('The :attribute must not point to localhost or internal hosts.');
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 12:46:46 +00:00
|
|
|
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
2026-07-02 14:35:39 +00:00
|
|
|
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
|
2026-07-02 12:46:46 +00:00
|
|
|
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
|
2026-07-02 14:35:39 +00:00
|
|
|
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
|
2026-07-02 12:46:46 +00:00
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-28 11:22:59 +00:00
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-02 12:46:46 +00:00
|
|
|
|
|
|
|
|
$resolvedIps = $this->resolveHost($hostForDns);
|
2026-07-02 14:35:39 +00:00
|
|
|
if ($resolvedIps === []) {
|
|
|
|
|
$fail('The :attribute host could not be resolved.');
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 12:46:46 +00:00
|
|
|
foreach ($resolvedIps as $resolvedIp) {
|
2026-07-02 14:35:39 +00:00
|
|
|
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
|
2026-07-02 12:46:46 +00:00
|
|
|
$this->logBlockedIp($attribute, $host, $resolvedIp);
|
2026-07-02 14:35:39 +00:00
|
|
|
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
|
2026-07-02 12:46:46 +00:00
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-28 11:22:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
/**
|
|
|
|
|
* Build HTTP client options that pin the validated host to the resolved IPs.
|
|
|
|
|
*
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
*/
|
|
|
|
|
public static function httpClientOptions(string $url): array
|
|
|
|
|
{
|
|
|
|
|
$options = ['allow_redirects' => false];
|
|
|
|
|
|
|
|
|
|
if (! defined('CURLOPT_RESOLVE')) {
|
|
|
|
|
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$target = self::resolveUrlForRequest($url);
|
|
|
|
|
|
|
|
|
|
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
|
|
|
|
|
return $options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$options['curl'] = [
|
|
|
|
|
CURLOPT_RESOLVE => array_map(
|
|
|
|
|
fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
|
|
|
|
|
$target['ips'],
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return $options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build mc --resolve mappings that pin the endpoint host for S3 backups.
|
|
|
|
|
*
|
|
|
|
|
* @return array<int, string>
|
|
|
|
|
*/
|
|
|
|
|
public static function minioClientResolveOptions(string $url): array
|
|
|
|
|
{
|
|
|
|
|
$target = self::resolveUrlForRequest($url);
|
|
|
|
|
|
|
|
|
|
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return array_map(
|
|
|
|
|
fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
|
|
|
|
|
$target['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
|
|
|
|
|
{
|
|
|
|
|
$rule = new self;
|
|
|
|
|
$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) ?? '');
|
|
|
|
|
$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.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 12:46:46 +00:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:47:55 +00:00
|
|
|
if ($host === 'localhost') {
|
|
|
|
|
return ['127.0.0.1', '::1'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$customDnsServers = $this->customDnsServers();
|
|
|
|
|
if ($customDnsServers !== []) {
|
|
|
|
|
return $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 12:46:46 +00:00
|
|
|
$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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:47:55 +00:00
|
|
|
/**
|
|
|
|
|
* @param array<int, string> $dnsServers
|
|
|
|
|
* @return array<int, string>
|
|
|
|
|
*/
|
|
|
|
|
private 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
private function isAllowedIp(string $ip, string $host): bool
|
2026-07-02 12:46:46 +00:00
|
|
|
{
|
|
|
|
|
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
|
|
|
|
if ($embeddedIpv4 !== null) {
|
2026-07-02 14:35:39 +00:00
|
|
|
$ip = $embeddedIpv4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->isAllowlistedIp($ip);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-02 12:46:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
2026-07-02 14:35:39 +00:00
|
|
|
return $this->ipv4InCidr($ip, '127.0.0.0/8');
|
2026-07-02 12:46:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
return @inet_pton($ip) === @inet_pton('::1');
|
2026-07-02 12:46:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
private function isPrivateIp(string $ip): bool
|
2026-03-28 11:22:59 +00:00
|
|
|
{
|
2026-07-02 14:35:39 +00:00
|
|
|
$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);
|
2026-03-28 11:22:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
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) {
|
2026-07-02 12:46:46 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
foreach ([
|
|
|
|
|
'::/128',
|
|
|
|
|
'::1/128',
|
|
|
|
|
'::ffff:0:0/96',
|
|
|
|
|
'64:ff9b::/96',
|
|
|
|
|
'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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-28 11:22:59 +00:00
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
return false;
|
2026-03-28 11:22:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
private function isBlockedHostname(string $host): bool
|
2026-03-28 11:22:59 +00:00
|
|
|
{
|
2026-07-02 14:35:39 +00:00
|
|
|
return in_array($host, ['localhost'], true)
|
|
|
|
|
|| str_ends_with($host, '.local')
|
|
|
|
|
|| str_ends_with($host, '.internal')
|
|
|
|
|
|| str_ends_with($host, '.cluster.local');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function isAllowedHostname(string $host): bool
|
|
|
|
|
{
|
|
|
|
|
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)) {
|
2026-03-28 11:22:59 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
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;
|
2026-07-02 12:46:46 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
$ipBytes = @inet_pton($ip);
|
|
|
|
|
$networkBytes = @inet_pton($network);
|
|
|
|
|
if ($ipBytes === false || $networkBytes === false) {
|
2026-07-02 12:46:46 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
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;
|
2026-03-28 11:22:59 +00:00
|
|
|
|
2026-07-02 14:35:39 +00:00
|
|
|
return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
|
2026-07-02 12:46:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
]);
|
2026-03-28 11:22:59 +00:00
|
|
|
}
|
|
|
|
|
}
|