fix(server-transfer): harden import on Cloud and stop archives from using server slots

- Rate limit the import check probe and refuse private, loopback, and
  link-local targets on Cloud.
- Rate limit the SSH validation that an import starts, like other
  server validations.
- Count only managed servers for the server limit, the limit check job,
  and the subscription page. Archives of transferred servers are left out.
- Do not show the name of another team's archived server in the import check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-23 20:12:59 +02:00
co-authored by Claude Opus 5.5
parent 30d840f1c6
commit 95a3a22fdf
9 changed files with 106 additions and 9 deletions
@@ -241,6 +241,23 @@ describe('POST /api/v1/servers/import', function () {
->assertJsonValidationErrors(['preserve_uuids']);
});
test('on Cloud, rate limits the SSH validation that the import starts', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->update(['custom_server_limit' => 10]);
InstanceSettings::findOrFail(0)->forceFill(['server_validation_max_failures_per_ip' => 1])->save();
Server::throttleValidation($this->team->id, '10.66.0.20');
$export = transferExportForApiTest($this->server);
$this->server->forceDelete();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(429);
expect(Server::where('uuid', $export['server']['uuid'])->exists())->toBeFalse();
Queue::assertNotPushed(ValidateAndInstallServerJob::class);
});
test('rejects import when the destination team is at its cloud server limit', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->update(['custom_server_limit' => 1]);
@@ -475,6 +492,30 @@ describe('GET /api/v1/servers/import/check', function () {
fclose($listener);
});
test('on Cloud, does not probe private, loopback, or link-local addresses', function (string $ip) {
config()->set('constants.coolify.self_hosted', false);
$this->withHeaders(transferHeaders($this->importToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip).'&port=22')
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'cannot connect to a private, loopback, or link-local address'));
})->with(['127.0.0.1', '10.0.0.5', '169.254.169.254', '::1', 'localhost']);
test('on Cloud, rate limits the reachability probe', function () {
config()->set('constants.coolify.self_hosted', false);
InstanceSettings::findOrFail(0)->forceFill(['server_validation_max_failures_per_team' => 2])->save();
foreach (['10.0.0.5', '10.0.0.6'] as $ip) {
$this->withHeaders(transferHeaders($this->importToken))
->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip={$ip}")
->assertStatus(422);
}
$this->withHeaders(transferHeaders($this->importToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.0.0.7')
->assertStatus(429);
});
test('rejects a token without the write permission', function () {
$this->withHeaders(transferHeaders($this->readToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99')
+20
View File
@@ -81,3 +81,23 @@ it('does not change servers when under limit and none are force-disabled', funct
expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
});
it('does not count archives of transferred servers against the limit', function () {
$this->team->update(['custom_server_limit' => 1]);
$live = createServerForTeam($this->team);
sleep(1);
$archive = createServerForTeam($this->team);
$archive->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->save();
ServerLimitCheckJob::dispatchSync($this->team);
expect($live->fresh()->settings->force_disabled)->toBeFalsy()
->and(Team::serverLimitReached($this->team))->toBeTrue()
->and($this->team->serverOverflow())->toBeFalse();
Notification::assertNothingSent();
$live->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->save();
expect(Team::serverLimitReached($this->team))->toBeFalse();
});
@@ -525,7 +525,10 @@ test('the archive of another team is not replaced', function () {
$otherTeam = Team::factory()->create();
expect(fn () => $this->importer->import($bundle, teamId: $otherTeam->id))
->toThrow(ValidationException::class, 'Another team of this instance has the archived copy');
->toThrow(ValidationException::class, 'Another team of this instance has the archived copy of this server.');
$uuidCheck = collect($this->importer->importChecks($this->server->uuid, '203.0.113.50', $otherTeam->id))->firstWhere('field', 'server.uuid');
expect($uuidCheck['message'])->not->toContain($this->server->name);
});
test('import rolls back all created rows when a later resource fails', function () {