diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index e7d6d071b4..f3d519b0bd 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -15,7 +15,7 @@ class SshMultiplexingHelper { public static function serverSshConfiguration(Server $server): array { - $privateKey = PrivateKey::findOrFail($server->private_key_id); + $privateKey = PrivateKey::where('team_id', $server->team_id)->findOrFail($server->private_key_id); return [ 'sshKeyLocation' => $privateKey->getKeyLocation(), diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index db0cc74d4b..6d35d73df8 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -10,6 +10,7 @@ use App\Models\Team; use App\Rules\ValidServerIp; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\Rule; use Livewire\Attributes\Locked; use Livewire\Component; @@ -52,7 +53,7 @@ class ByIp extends Component protected function rules(): array { return [ - 'private_key_id' => 'nullable|integer', + 'private_key_id' => ['nullable', 'integer', Rule::exists('private_keys', 'id')->where('team_id', currentTeam()->id)], 'new_private_key_name' => 'nullable|string', 'new_private_key_description' => 'nullable|string', 'new_private_key_value' => 'nullable|string', @@ -70,6 +71,7 @@ class ByIp extends Component return array_merge(ValidationPatterns::combinedMessages(), [ 'private_key_id.integer' => 'The Private Key field must be an integer.', 'private_key_id.nullable' => 'The Private Key field is optional.', + 'private_key_id.exists' => 'The selected Private Key is invalid.', 'new_private_key_name.string' => 'The Private Key Name must be a string.', 'new_private_key_description.string' => 'The Private Key Description must be a string.', 'new_private_key_value.string' => 'The Private Key Value must be a string.', diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 87c359fd07..359ad4481f 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -216,12 +216,17 @@ function deleteBackupsLocally(string|array|null $filenames, Server $server, bool function streamBackupFromServer(Server $server, string $filename, string $contentType): StreamedResponse { + $privateKey = $server->privateKey; + if (! $privateKey || $privateKey->team_id !== $server->team_id) { + throw new RuntimeException('Private key not found for this server.'); + } + $disk = Storage::build([ 'driver' => 'sftp', 'host' => $server->ip, 'port' => (int) $server->port, 'username' => $server->user, - 'privateKey' => $server->privateKey->getKeyLocation(), + 'privateKey' => $privateKey->getKeyLocation(), 'root' => '/', ]); diff --git a/tests/Feature/NewServerPrivateKeySelectionTest.php b/tests/Feature/NewServerPrivateKeySelectionTest.php index ff850eb3d9..0ec0a60d92 100644 --- a/tests/Feature/NewServerPrivateKeySelectionTest.php +++ b/tests/Feature/NewServerPrivateKeySelectionTest.php @@ -1,10 +1,13 @@ assertSet('server_role', 'build') ->assertSee('Manual SSH Key'); }); + +it('only accepts a private key from the current team when adding a server', function () { + $otherTeamKey = PrivateKey::factory()->create(['team_id' => Team::factory()->create()->id]); + + Livewire::test(ByIp::class, [ + 'private_keys' => collect([$this->existingPrivateKey]), + 'limit_reached' => false, + ]) + ->set('ip', '192.0.2.60') + ->set('private_key_id', $otherTeamKey->id) + ->call('submit') + ->assertHasErrors(['private_key_id']); + + expect(Server::where('ip', '192.0.2.60')->exists())->toBeFalse(); + + Livewire::test(ByIp::class, [ + 'private_keys' => collect([$this->existingPrivateKey]), + 'limit_reached' => false, + ]) + ->set('ip', '192.0.2.61') + ->set('private_key_id', $this->existingPrivateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::where('ip', '192.0.2.61')->value('private_key_id'))->toBe($this->existingPrivateKey->id); +}); + +it('ssh configuration only uses a private key from the server team', function () { + $otherTeamKey = PrivateKey::factory()->create(['team_id' => Team::factory()->create()->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->existingPrivateKey->id, + ]); + + expect(SshMultiplexingHelper::serverSshConfiguration($server)['sshKeyLocation']) + ->toBe($this->existingPrivateKey->getKeyLocation()); + + Server::query()->whereKey($server->id)->update(['private_key_id' => $otherTeamKey->id]); + + expect(fn () => SshMultiplexingHelper::serverSshConfiguration($server->fresh())) + ->toThrow(ModelNotFoundException::class); +}); diff --git a/tests/Unit/BackupDownloadHelperTest.php b/tests/Unit/BackupDownloadHelperTest.php index 8ed499df67..9db77c642a 100644 --- a/tests/Unit/BackupDownloadHelperTest.php +++ b/tests/Unit/BackupDownloadHelperTest.php @@ -13,9 +13,11 @@ function backupDownloadServer(): Server 'ip' => '192.0.2.10', 'port' => 2222, 'user' => 'root', + 'team_id' => 5, ]); - $privateKey = Mockery::mock(PrivateKey::class); + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->forceFill(['team_id' => 5]); $privateKey->shouldReceive('getKeyLocation')->andReturn('/tmp/private-key'); $server->setRelation('privateKey', $privateKey); @@ -44,6 +46,15 @@ it('throws when the backup file does not exist on the server', function () { streamBackupFromServer(backupDownloadServer(), '/backups/archive.tar.gz', 'application/gzip'); })->throws(FileNotFoundException::class); +it('refuses a private key from another team', function () { + $server = backupDownloadServer(); + $server->privateKey->forceFill(['team_id' => 6]); + + Storage::shouldReceive('build')->never(); + + streamBackupFromServer($server, '/backups/archive.tar.gz', 'application/gzip'); +})->throws(RuntimeException::class); + it('streams a backup file with the requested content type', function () { $stream = fopen('php://memory', 'r+'); fwrite($stream, 'backup contents');