From 74ebaef17b3b9ef2ddc63d95e2afa031b13af98a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:26:39 +0200 Subject: [PATCH] feat(api): enhance IP access control in middleware and settings; support CIDR notation and special case for 0.0.0.0 to allow all IPs --- app/Http/Middleware/ApiAllowed.php | 18 ++++--- app/Livewire/Settings/Advanced.php | 87 ++++++++++++++++++++++++++++-- bootstrap/helpers/shared.php | 58 ++++++++++++++++++++ 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index dc6be5da3..dd85c3521 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -18,12 +18,18 @@ class ApiAllowed return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); } - if (! isDev()) { - if ($settings->allowed_ips) { - $allowedIps = explode(',', $settings->allowed_ips); - if (! in_array($request->ip(), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); - } + if ($settings->allowed_ips) { + // Check for special case: 0.0.0.0 means allow all + if (trim($settings->allowed_ips) === '0.0.0.0') { + return $next($request); + } + + $allowedIps = explode(',', $settings->allowed_ips); + $allowedIps = array_map('trim', $allowedIps); + $allowedIps = array_filter($allowedIps); // Remove empty entries + + if (! empty($allowedIps) && ! check_ip_against_allowlist($request->ip(), $allowedIps)) { + return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 4425b414d..832123d5a 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -4,6 +4,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; use App\Models\Server; +use App\Rules\ValidIpOrCidr; use Auth; use Hash; use Livewire\Attributes\Validate; @@ -31,7 +32,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_api_enabled; - #[Validate('nullable|string')] public ?string $allowed_ips = null; #[Validate('boolean')] @@ -40,6 +40,21 @@ class Advanced extends Component #[Validate('boolean')] public bool $disable_two_step_confirmation; + public function rules() + { + return [ + 'server' => 'required', + 'is_registration_enabled' => 'boolean', + 'do_not_track' => 'boolean', + 'is_dns_validation_enabled' => 'boolean', + 'custom_dns_servers' => 'nullable|string', + 'is_api_enabled' => 'boolean', + 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], + 'is_sponsorship_popup_enabled' => 'boolean', + 'disable_two_step_confirmation' => 'boolean', + ]; + } + public function mount() { if (! isInstanceAdmin()) { @@ -67,12 +82,76 @@ class Advanced extends Component return str($dns)->trim()->lower(); })->unique()->implode(','); + // Handle allowed IPs with subnet support and 0.0.0.0 special case $this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim(); - $this->allowed_ips = str($this->allowed_ips)->trim()->explode(',')->map(function ($ip) { - return str($ip)->trim(); - })->unique()->implode(','); + + // Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere) + $allowsFromAnywhere = false; + if (empty($this->allowed_ips)) { + $allowsFromAnywhere = true; + } elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) { + $allowsFromAnywhere = true; + } + + // Check if it's 0.0.0.0 (allow all) or empty + if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) { + // Keep as is - empty means no restriction, 0.0.0.0 means allow all + } else { + // Validate and clean up the entries + $invalidEntries = []; + $validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) { + $entry = str($entry)->trim()->toString(); + + if (empty($entry)) { + return null; + } + + // Check if it's valid CIDR notation + if (str_contains($entry, '/')) { + [$ip, $mask] = explode('/', $entry); + if (filter_var($ip, FILTER_VALIDATE_IP) && is_numeric($mask) && $mask >= 0 && $mask <= 32) { + return $entry; + } + $invalidEntries[] = $entry; + + return null; + } + + // Check if it's a valid IP address + if (filter_var($entry, FILTER_VALIDATE_IP)) { + return $entry; + } + + $invalidEntries[] = $entry; + + return null; + })->filter()->unique(); + + if (! empty($invalidEntries)) { + $this->dispatch('error', 'Invalid IP addresses or subnets: '.implode(', ', $invalidEntries)); + + return; + } + + // Also check if we have no valid entries after filtering + if ($validEntries->isEmpty()) { + $this->dispatch('error', 'No valid IP addresses or subnets provided'); + + return; + } + + $this->allowed_ips = $validEntries->implode(','); + } $this->instantSave(); + + // Show security warning if allowing access from anywhere + if ($allowsFromAnywhere) { + $message = empty($this->allowed_ips) + ? 'Empty IP allowlist allows API access from anywhere.

This is not recommended for production environments!' + : 'Using 0.0.0.0 allows API access from anywhere.

This is not recommended for production environments!'; + $this->dispatch('warning', $message); + } } catch (\Exception $e) { return handleError($e, $this); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 23393a4a4..88bcd5538 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1025,6 +1025,64 @@ function ip_match($ip, $cidrs, &$match = null) return false; } + +function check_ip_against_allowlist($ip, $allowlist) +{ + if (empty($allowlist)) { + return false; + } + + foreach ((array) $allowlist as $allowed) { + $allowed = trim($allowed); + + if (empty($allowed)) { + continue; + } + + // Check if it's a CIDR notation + if (str_contains($allowed, '/')) { + [$subnet, $mask] = explode('/', $allowed); + + // Special case: 0.0.0.0 with any subnet means allow all + if ($subnet === '0.0.0.0') { + return true; + } + + $mask = (int) $mask; + + // Validate mask + if ($mask < 0 || $mask > 32) { + continue; + } + + // Calculate network addresses + $ip_long = ip2long($ip); + $subnet_long = ip2long($subnet); + + if ($ip_long === false || $subnet_long === false) { + continue; + } + + $mask_long = ~((1 << (32 - $mask)) - 1); + + if (($ip_long & $mask_long) == ($subnet_long & $mask_long)) { + return true; + } + } else { + // Special case: 0.0.0.0 means allow all + if ($allowed === '0.0.0.0') { + return true; + } + + // Direct IP comparison + if ($ip === $allowed) { + return true; + } + } + } + + return false; +} function checkIfDomainIsAlreadyUsed(Collection|array $domains, ?string $teamId = null, ?string $uuid = null) { if (is_null($teamId)) {