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)) {