From 9ccd4f59d8d1ae581634a4559164573880d5abc1 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 20:27:26 +0200 Subject: [PATCH] feat(security): the console decides who may reach it, and the owner keeps the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RestrictAdminHost answers under which NAME the console responds — and the caller picks the Host header, so it can never answer who is asking. This does: a network gate on the client address, which behind a trusted proxy is not something the client chooses. The management VPN is always in the list and cannot be removed — it is the one way in that survives a bad entry. Beyond that the owner keeps their own addresses in the console, because being away from the VPN should not mean being locked out, and the person who needs to change that list is the person sitting in front of it. Two refusals rather than warnings, since by the time a warning renders the request that would show it has already been rejected: switching the restriction ON is refused unless the address doing the switching is already covered, and removing the entry you are sitting behind is refused. Entries are validated as an address or CIDR — a typo that matches nothing is how someone locks themselves out while believing they have not. 404, never 403. Registered as persistent Livewire middleware, and verified in a browser that it holds there: with the settings page open, narrowing the list turned the next action from 200 into 404. Codex read the path guard as skipping /livewire/update; Livewire in fact replays middleware against a duplicate of the request carrying the original component's path, so the guard matches and the client address is preserved. Co-Authored-By: Claude Opus 5 --- .../Middleware/RestrictConsoleNetwork.php | 108 +++++++++++++++++ app/Livewire/Admin/Settings.php | 109 +++++++++++++++++ app/Providers/AppServiceProvider.php | 2 + bootstrap/app.php | 5 + lang/de/admin_settings.php | 24 ++++ lang/en/admin_settings.php | 24 ++++ .../views/livewire/admin/settings.blade.php | 59 +++++++++ tests/Feature/Admin/ConsoleNetworkTest.php | 112 ++++++++++++++++++ 8 files changed, 443 insertions(+) create mode 100644 app/Http/Middleware/RestrictConsoleNetwork.php create mode 100644 tests/Feature/Admin/ConsoleNetworkTest.php diff --git a/app/Http/Middleware/RestrictConsoleNetwork.php b/app/Http/Middleware/RestrictConsoleNetwork.php new file mode 100644 index 0000000..e7570bb --- /dev/null +++ b/app/Http/Middleware/RestrictConsoleNetwork.php @@ -0,0 +1,108 @@ +is('admin', 'admin/*')) { + return $next($request); + } + + if (! self::isRestricted()) { + return $next($request); + } + + if (! self::allows((string) $request->ip())) { + abort(404); + } + + return $next($request); + } + + /** Whether the owner has switched the restriction on at all. */ + public static function isRestricted(): bool + { + return Settings::bool('console.network_restricted', false); + } + + /** + * Every network that may reach the console. + * + * The VPN is always in it and is not removable — it is the one path that + * exists independently of whatever the owner has typed into the list, and + * without it a bad entry would leave nobody able to fix the entry. + * + * @return array + */ + public static function allowedRanges(): array + { + $vpn = (array) config('admin_access.trusted_ranges', []); + $own = (array) Settings::get('console.allowed_ips', []); + + return array_values(array_unique(array_filter(array_merge( + $vpn, + array_map('trim', array_map('strval', $own)), + )))); + } + + public static function allows(string $ip): bool + { + return self::covers($ip, self::allowedRanges()); + } + + /** + * Would this address still get in, given that list? + * + * Split out so the "you are about to lock yourself out" decision can be + * made — and tested — without a request: it is the one check whose failure + * mode is that nobody can reach the page that would fix it. + * + * @param array $extra the owner's list AFTER the change + */ + public static function wouldStillAllow(string $ip, array $extra): bool + { + $vpn = (array) config('admin_access.trusted_ranges', []); + + return self::covers($ip, array_merge($vpn, $extra)); + } + + /** @param array $ranges */ + private static function covers(string $ip, array $ranges): bool + { + $ranges = array_values(array_filter($ranges)); + + return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 967c26c..3013611 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -235,6 +235,109 @@ class Settings extends Component return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count(); } + /** A new entry for the console allowlist: a single address or a CIDR range. */ + public string $consoleIp = ''; + + /** + * Add a network that may reach the console without the VPN. + * + * Validated as an address or CIDR before it is stored: a typo that silently + * matches nothing is how someone locks themselves out while believing they + * have not. + */ + public function addConsoleIp(): void + { + $this->authorize('site.manage'); + + $value = trim($this->consoleIp); + + if (! $this->looksLikeNetwork($value)) { + $this->addError('consoleIp', __('admin_settings.console_ip_invalid')); + + return; + } + + $list = (array) AppSettings::get('console.allowed_ips', []); + + if (! in_array($value, $list, true)) { + $list[] = $value; + AppSettings::set('console.allowed_ips', array_values($list)); + } + + $this->consoleIp = ''; + $this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value])); + } + + /** + * Remove one — unless it is the reason you can see this page. + * + * Refusing here rather than warning afterwards: by the time the page + * reloads, the request that would show the warning has already been + * rejected. The VPN is never in this list, so an operator on the VPN can + * always clear it out. + */ + public function removeConsoleIp(string $value): void + { + $this->authorize('site.manage'); + + $list = array_values(array_filter( + (array) AppSettings::get('console.allowed_ips', []), + fn ($entry) => $entry !== $value, + )); + + $ip = (string) request()->ip(); + + if (\App\Http\Middleware\RestrictConsoleNetwork::isRestricted() + && ! \App\Http\Middleware\RestrictConsoleNetwork::wouldStillAllow($ip, $list)) { + $this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip])); + + return; + } + + AppSettings::set('console.allowed_ips', $list); + $this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value])); + } + + /** + * Switch the restriction on or off. + * + * Turning it ON is refused unless the address doing the switching is + * already covered — otherwise the click that secures the console is also + * the click that locks its owner out of it. + */ + public function toggleConsoleRestriction(): void + { + $this->authorize('site.manage'); + + $on = ! \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(); + + if ($on && ! \App\Http\Middleware\RestrictConsoleNetwork::allows((string) request()->ip())) { + $this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()])); + + return; + } + + AppSettings::set('console.network_restricted', $on); + $this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked')); + } + + private function looksLikeNetwork(string $value): bool + { + [$address, $prefix] = array_pad(explode('/', $value, 2), 2, null); + + if (filter_var($address, FILTER_VALIDATE_IP) === false) { + return false; + } + + if ($prefix === null) { + return true; + } + + $max = str_contains($address, ':') ? 128 : 32; + + return ctype_digit($prefix) && (int) $prefix >= 0 && (int) $prefix <= $max; + } + public function render() { $staff = User::query() @@ -258,6 +361,12 @@ class Settings extends Component (string) request()->ip(), (array) config('admin_access.trusted_ranges', []), ), + // Who may reach the console, and from where the viewer is asking — + // shown so the consequence of a change is visible before it is made. + 'consoleRestricted' => \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(), + 'consoleIps' => (array) AppSettings::get('console.allowed_ips', []), + 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), + 'viewerIp' => (string) request()->ip(), 'staff' => $staff, 'roles' => User::OPERATOR_ROLES, 'canManageStaff' => auth()->user()->can('staff.manage'), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4f34806..e40acba 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,7 @@ use App\Services\Wireguard\WireguardHub; use App\Http\Middleware\EnsureAdmin; use App\Http\Middleware\EnsureCustomerActive; use App\Http\Middleware\RestrictAdminHost; +use App\Http\Middleware\RestrictConsoleNetwork; use App\Mail\MaintenanceCancelledMail; use App\Models\MaintenanceNotification; use App\Services\Maintenance\MaintenanceNotifier; @@ -67,6 +68,7 @@ class AppServiceProvider extends ServiceProvider // actions run. Livewire::addPersistentMiddleware([ RestrictAdminHost::class, + RestrictConsoleNetwork::class, EnsureAdmin::class, EnsureCustomerActive::class, ]); diff --git a/bootstrap/app.php b/bootstrap/app.php index 5199197..e776c6a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,11 @@ return Application::configure(basePath: dirname(__DIR__)) // that an operator console is hosted here. Self-scoped to /admin*. $middleware->prependToGroup('web', \App\Http\Middleware\RestrictAdminHost::class); + // And WHO may reach it, by network address — the VPN plus whatever the + // owner has listed in the console. The host guard above cannot answer + // that question: a Host header is chosen by the caller. + $middleware->prependToGroup('web', \App\Http\Middleware\RestrictConsoleNetwork::class); + // Runs after the host guard: the console must stay reachable even while // the public site is switched off. $middleware->appendToGroup('web', \App\Http\Middleware\PublicSiteGate::class); diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index ebc0e5b..70dead6 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -51,4 +51,28 @@ return [ 'last_owner' => 'Der letzte Inhaber kann nicht geändert oder entfernt werden.', 'not_staff' => 'Nur bestehende Mitarbeiter können bearbeitet werden.', 'is_customer' => 'Diese E-Mail gehört bereits zu einem Kundenkonto.', + + // Console network access — who may reach /admin at all. + 'console_title' => 'Zugang zur Konsole', + 'console_locked_badge' => 'Eingeschränkt', + 'console_open_badge' => 'Offen', + 'console_locked_body' => 'Nur das VPN und die unten gelisteten Adressen erreichen die Konsole. Alle anderen bekommen 404 — sie erfahren nicht einmal, dass es sie gibt.', + 'console_open_body' => 'Die Konsole ist von überall erreichbar, wo ihr Hostname aufgerufen wird. Solange das so ist, schützt sie allein das Passwort.', + 'console_your_ip' => 'Sie sehen diese Seite gerade von :ip', + 'console_lock' => 'Einschränken', + 'console_unlock' => 'Freigeben', + 'console_always' => 'Immer erlaubt', + 'console_vpn_note' => 'Management-VPN, nicht entfernbar', + 'console_extra' => 'Zusätzlich erlaubt', + 'console_none' => 'Noch keine weitere Adresse — ohne VPN kommt dann niemand herein.', + 'console_add' => 'Hinzufügen', + 'console_remove' => 'Entfernen', + 'console_ip_hint' => 'Einzelne Adresse oder Bereich, z. B. 203.0.113.7 oder 203.0.113.0/24.', + 'console_ip_invalid' => 'Das ist keine gültige IP-Adresse und kein gültiger Bereich.', + 'console_ip_added' => ':ip darf die Konsole jetzt erreichen.', + 'console_ip_removed' => ':ip wurde entfernt.', + 'console_ip_last' => 'Nicht entfernt: Sie greifen gerade selbst über :ip zu und würden sich aussperren.', + 'console_locked' => 'Konsole eingeschränkt — nur noch VPN und gelistete Adressen.', + 'console_unlocked' => 'Einschränkung aufgehoben.', + 'console_lock_refused' => 'Nicht eingeschränkt: :ip steht auf keiner Liste, Sie hätten sich damit ausgesperrt.', ]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index c32fde0..1ec01b2 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -51,4 +51,28 @@ return [ 'last_owner' => 'The last Owner cannot be changed or removed.', 'not_staff' => 'Only existing staff can be edited.', 'is_customer' => 'This email already belongs to a customer account.', + + // Console network access — who may reach /admin at all. + 'console_title' => 'Console access', + 'console_locked_badge' => 'Restricted', + 'console_open_badge' => 'Open', + 'console_locked_body' => 'Only the VPN and the addresses listed below reach the console. Everyone else gets a 404 — they do not even learn it exists.', + 'console_open_body' => 'The console answers wherever its hostname is used. While that is so, only the password protects it.', + 'console_your_ip' => 'You are viewing this from :ip', + 'console_lock' => 'Restrict', + 'console_unlock' => 'Open up', + 'console_always' => 'Always allowed', + 'console_vpn_note' => 'Management VPN, not removable', + 'console_extra' => 'Additionally allowed', + 'console_none' => 'No further address yet — without the VPN, nobody gets in.', + 'console_add' => 'Add', + 'console_remove' => 'Remove', + 'console_ip_hint' => 'A single address or a range, e.g. 203.0.113.7 or 203.0.113.0/24.', + 'console_ip_invalid' => 'That is not a valid IP address or range.', + 'console_ip_added' => ':ip may now reach the console.', + 'console_ip_removed' => ':ip removed.', + 'console_ip_last' => 'Not removed: you are connecting from :ip yourself and would lock yourself out.', + 'console_locked' => 'Console restricted — VPN and listed addresses only.', + 'console_unlocked' => 'Restriction lifted.', + 'console_lock_refused' => 'Not restricted: :ip is on no list, so this would have locked you out.', ]; diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index b482ae8..fc718f0 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -35,6 +35,65 @@ @endif + {{-- Who may reach this console --}} + @if ($canManageSite) +
+
+
+
+

{{ __('admin_settings.console_title') }}

+ + + {{ $consoleRestricted ? __('admin_settings.console_locked_badge') : __('admin_settings.console_open_badge') }} + +
+

+ {{ $consoleRestricted ? __('admin_settings.console_locked_body') : __('admin_settings.console_open_body') }} +

+

+ {{ __('admin_settings.console_your_ip', ['ip' => $viewerIp]) }} +

+
+ + + {{ $consoleRestricted ? __('admin_settings.console_unlock') : __('admin_settings.console_lock') }} + +
+ +
+

{{ __('admin_settings.console_always') }}

+ @foreach ($consoleVpnRanges as $range) +
+ {{ $range }} + {{ __('admin_settings.console_vpn_note') }} +
+ @endforeach + +

{{ __('admin_settings.console_extra') }}

+ @forelse ($consoleIps as $ip) +
+ {{ $ip }} + + {{ __('admin_settings.console_remove') }} + +
+ @empty +

{{ __('admin_settings.console_none') }}

+ @endforelse + +
+
+ +
+ {{ __('admin_settings.console_add') }} +
+
+
+ @endif +

{{ __('admin_settings.account_title') }}

diff --git a/tests/Feature/Admin/ConsoleNetworkTest.php b/tests/Feature/Admin/ConsoleNetworkTest.php new file mode 100644 index 0000000..9150155 --- /dev/null +++ b/tests/Feature/Admin/ConsoleNetworkTest.php @@ -0,0 +1,112 @@ +server->set('REMOTE_ADDR', $ip); +} + +beforeEach(function () { + config()->set('admin_access.hosts', []); + config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']); + Settings::set('console.network_restricted', false); + Settings::set('console.allowed_ips', []); +}); + +it('changes nothing until the owner switches it on', function () { + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->get('/admin') + ->assertOk(); +}); + +it('lets the management VPN in and turns everyone else away', function () { + Settings::set('console.network_restricted', true); + + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '10.66.0.5']) + ->get('/admin') + ->assertOk(); + + // 404, never 403: a stranger must not learn a console lives here. + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->get('/admin') + ->assertNotFound(); +}); + +it('lets in an address the owner has listed, without the VPN', function () { + Settings::set('console.network_restricted', true); + Settings::set('console.allowed_ips', ['203.0.113.0/24']); + + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '203.0.113.9']) + ->get('/admin') + ->assertOk(); + + // A neighbouring range is still nobody. + $this->actingAs(operator('Owner')) + ->withServerVariables(['REMOTE_ADDR' => '198.51.100.4']) + ->get('/admin') + ->assertNotFound(); +}); + +it('knows when a change would lock the owner out', function () { + // The one check whose failure mode is that nobody can reach the page that + // would fix it — so it is decided without a request, and tested that way. + expect(RestrictConsoleNetwork::wouldStillAllow('10.66.0.5', []))->toBeTrue() + ->and(RestrictConsoleNetwork::wouldStillAllow('198.51.100.4', []))->toBeFalse() + ->and(RestrictConsoleNetwork::wouldStillAllow('198.51.100.4', ['198.51.100.0/24']))->toBeTrue() + // Removing the entry you are sitting behind leaves you outside. + ->and(RestrictConsoleNetwork::wouldStillAllow('203.0.113.9', []))->toBeFalse(); +}); + +it('rejects an entry that is not an address or a range', function () { + // A typo that silently matches nothing is how someone locks themselves out + // while believing they have not. + foreach (['nonsense', '203.0.113.', '203.0.113.9/99', ''] as $bad) { + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->set('consoleIp', $bad) + ->call('addConsoleIp') + ->assertHasErrors('consoleIp'); + } + + expect(Settings::get('console.allowed_ips'))->toBe([]); + + Livewire::actingAs(operator('Owner')) + ->test(AdminSettings::class) + ->set('consoleIp', '203.0.113.0/24') + ->call('addConsoleIp') + ->assertHasNoErrors(); + + expect(Settings::get('console.allowed_ips'))->toBe(['203.0.113.0/24']); +}); + +it('needs the capability, not merely an operator account', function () { + Livewire::actingAs(operator('Support')) + ->test(AdminSettings::class) + ->call('addConsoleIp') + ->assertForbidden(); +}); + +it('is re-checked on every Livewire action, not only on the page', function () { + // The page is not where the actions run. + $persistent = app(\Livewire\Mechanisms\PersistentMiddleware\PersistentMiddleware::class) + ->getPersistentMiddleware(); + + expect($persistent)->toContain(RestrictConsoleNetwork::class); +});