feat: add internal endpoint controls

This commit is contained in:
Andras Bacsai committed 2026-07-02 16:35:39 +02:00
1 parent 7a853efa79
commit 0bf97df9af
13 files changed
+713 -86

No files matched your search

+373 -47
View File
@@ -2,9 +2,11 @@
namespace App\Rules;
use App\Models\InstanceSettings;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
use Throwable;
class SafeWebhookUrl implements ValidationRule
{
@@ -18,8 +20,8 @@ class SafeWebhookUrl implements ValidationRule
*
* 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.
* 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
{
@@ -43,12 +45,17 @@ class SafeWebhookUrl implements ValidationRule
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, '.');
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
$this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.');
@@ -56,9 +63,9 @@ class SafeWebhookUrl implements ValidationRule
}
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if ($this->isBlockedIp($hostForIpCheck)) {
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to loopback or link-local addresses.');
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return;
}
@@ -67,16 +74,124 @@ class SafeWebhookUrl implements ValidationRule
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) {
if ($this->isBlockedIp($resolvedIp)) {
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to loopback or link-local addresses.');
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return;
}
}
}
/**
* 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];
}
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
@@ -119,60 +234,271 @@ class SafeWebhookUrl implements ValidationRule
return array_values(array_unique($ips));
}
private function isBlockedIp(string $ip): bool
private function isAllowedIp(string $ip, string $host): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isBlockedIpv4($embeddedIpv4);
$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);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isBlockedIpv4($ip);
return $this->ipv4InCidr($ip, '127.0.0.0/8');
}
return $this->isBlockedIpv6($ip);
return @inet_pton($ip) === @inet_pton('::1');
}
private function isBlockedIpv4(string $ip): bool
private function isPrivateIp(string $ip): bool
{
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
$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',
'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
{
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)) {
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;
}
$long = ip2long($ip);
if ($long === false) {
return false;
}
$mask = 0xFF << (8 - $bits) & 0xFF;
$unsigned = sprintf('%u', $long);
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
}
private function isBlockedIpv6(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
return true;
}
$bytes = unpack('C16', $packed);
if ($bytes === false) {
return false;
}
$firstByte = $bytes[1];
$secondByte = $bytes[2];
// fe80::/10 link-local and fc00::/7 unique local addresses.
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|| (($firstByte & 0xFE) === 0xFC);
return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string