fix(servers): validate the selected private key when adding a server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-28 11:35:46 +02:00
co-authored by Claude Opus 5.5
parent 6c3249d324
commit f8b320ab40
5 changed files with 67 additions and 4 deletions
+1 -1
View File
@@ -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(),
+3 -1
View File
@@ -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.',
+6 -1
View File
@@ -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' => '/',
]);
@@ -1,10 +1,13 @@
<?php
use App\Helpers\SshMultiplexingHelper;
use App\Livewire\Server\New\ByIp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
@@ -91,3 +94,45 @@ it('preselects a manually added private key without clearing server form data',
->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);
});
+12 -1
View File
@@ -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');