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); }); it('offers a way back in that does not depend on the console', function () { // Every gate needs a door that does not depend on itself: the list is // managed from the console, and the address you manage it from can change. Settings::set('console.network_restricted', true); Settings::set('console.allowed_ips', []); $this->artisan('clupilot:console-access allow 203.0.113.42')->assertSuccessful(); expect(Settings::get('console.allowed_ips'))->toBe(['203.0.113.42']) ->and(RestrictConsoleNetwork::wouldStillAllow('203.0.113.42', ['203.0.113.42']))->toBeTrue(); $this->artisan('clupilot:console-access deny 203.0.113.42')->assertSuccessful(); expect(Settings::get('console.allowed_ips'))->toBe([]); // And the restriction itself can be lifted from the shell — the only place // that is reachable when the gate has shut on its own owner. $this->artisan('clupilot:console-access open')->assertSuccessful(); expect(RestrictConsoleNetwork::isRestricted())->toBeFalse(); }); it('tells the reverse proxy the same thing it tells itself', function () { // The proxy has its own allowlist and it runs FIRST. If the two disagree, // everything the owner changes in the console is silently ineffective — // and when their address changes they are turned away before reaching the // page that would have fixed it. Settings::set('console.network_restricted', true); Settings::set('console.allowed_ips', ['203.0.113.7']); // One assertion on the whole line: the ranges have to arrive together, in // the matcher, not merely appear somewhere in the output. $this->artisan('clupilot:console-access caddy') ->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7') ->assertSuccessful(); }); it('never opens the proxy to everyone, not even when the restriction is lifted', function () { // The rule is absolute: the console is reachable over the VPN or from a // listed address, and nowhere else. Letting the console's own switch also // open the PROXY would put it on the public internet with one click. Settings::set('console.network_restricted', false); Settings::set('console.allowed_ips', ['203.0.113.7']); $this->artisan('clupilot:console-access caddy') ->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7') ->assertSuccessful(); $this->artisan('clupilot:console-access caddy') ->doesntExpectOutputToContain('0.0.0.0/0') ->assertSuccessful(); }); it('never emits a matcher that lets nobody in', function () { // An empty remote_ip matcher matches NOTHING in Caddy — the console would // be unreachable from everywhere, including from where it gets fixed. config()->set('admin_access.trusted_ranges', []); Settings::set('console.network_restricted', true); Settings::set('console.allowed_ips', []); $this->artisan('clupilot:console-access caddy') ->expectsOutputToContain('127.0.0.1') ->assertSuccessful(); }); it('lets an operator change their own password', function () { // There was no way to do it at all: Fortify's updatePasswords feature was // off, so an account created with a generated password kept it until // someone opened a shell on the server. $operator = operator('Owner'); Livewire::actingAs($operator) ->test(AdminSettings::class) ->set('currentPassword', 'password') ->set('newPassword', 'ein-neues-langes-passwort') ->set('newPasswordConfirmation', 'ein-neues-langes-passwort') ->call('updateOwnPassword') ->assertHasNoErrors(); expect(Illuminate\Support\Facades\Hash::check('ein-neues-langes-passwort', $operator->refresh()->password))->toBeTrue(); }); it('refuses to change a password without the current one', function () { // Otherwise an unlocked machine is enough to lock the owner out of their // own account with two keystrokes. $operator = operator('Owner'); Livewire::actingAs($operator) ->test(AdminSettings::class) ->set('currentPassword', 'falsch') ->set('newPassword', 'ein-neues-langes-passwort') ->set('newPasswordConfirmation', 'ein-neues-langes-passwort') ->call('updateOwnPassword') ->assertHasErrors('currentPassword'); expect(Illuminate\Support\Facades\Hash::check('password', $operator->refresh()->password))->toBeTrue(); });