fix(application): reject Docker image names shared by building applications

Two applications that build the same Docker image name push the same
tags (commit SHA, latest, pr-<id>), so one can run the image of the
other. Deployments, the General form, and the API now reject such a
name for applications of the same team.

Closes #9834
This commit is contained in:
Andras Bacsai
2026-10-29 11:30:25 +01:00
parent 3048e9948c
commit b41ba87fff
5 changed files with 254 additions and 0 deletions
@@ -222,6 +222,19 @@ class ApplicationsController extends Controller
], 422);
}
private function sharedImageNameValidationResponse(Application $application): ?JsonResponse
{
$otherApplication = $application->otherApplicationBuildingSameImage();
if (! $otherApplication) {
return null;
}
return response()->json([
'message' => 'Validation failed.',
'errors' => ['docker_registry_image_name' => [$application->sharedImageNameMessage($otherApplication)]],
], 422);
}
private function applyApplicationSettings(Application $application, array $settings): void
{
if ($settings === []) {
@@ -1665,6 +1678,9 @@ class ApplicationsController extends Controller
$application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
if (isset($isStatic)) {
@@ -1925,6 +1941,9 @@ class ApplicationsController extends Controller
$application->source_id = $githubApp->id;
$application->repository_project_id = $repository_project_id;
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
@@ -2154,6 +2173,9 @@ class ApplicationsController extends Controller
$application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass();
$application->environment_id = $environment->id;
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
@@ -2302,6 +2324,9 @@ class ApplicationsController extends Controller
$application->git_repository = 'coollabsio/coolify';
$application->git_branch = 'main';
if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) {
return $imageNameValidation;
}
$application->save();
$this->applyApplicationSettings($application, $applicationSettings);
$application->refresh();
@@ -3525,6 +3550,10 @@ class ApplicationsController extends Controller
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
return $prefixValidation;
}
$requestedImage = (clone $application)->fill($request->only(['docker_registry_image_name', 'build_pack']));
if ($requestedImage->isDirty(['docker_registry_image_name', 'build_pack']) && $imageNameValidation = $this->sharedImageNameValidationResponse($requestedImage)) {
return $imageNameValidation;
}
if ($internalNameValidation = $this->customInternalNameValidationResponse($applicationSettings)) {
return $internalNameValidation;
}
+12
View File
@@ -370,6 +370,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
try {
$this->validateDeploymentEnvironmentVariableKeys();
$this->ensureRegistryImageForMultipleServers();
$this->ensureRegistryImageIsNotShared();
$this->warnAboutVolumesOnMultipleServers();
$this->warnAboutMixedProxiesOnMultipleServers();
} catch (Exception $e) {
@@ -2590,6 +2591,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw new DeploymentException('Before deploying to multiple servers, you must set a Docker image in the General tab. More information: https://coolify.io/docs/knowledge-base/server/multiple-servers');
}
/**
* Two applications that build the same Docker image name overwrite each other's image tags.
*/
private function ensureRegistryImageIsNotShared(): void
{
$otherApplication = $this->application->otherApplicationBuildingSameImage();
if ($otherApplication) {
throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication));
}
}
/**
* Build-time names go into shell and Docker build commands, so they must be valid when the
* deployment builds an image ($buildsImage, checked right before the build steps). Restarts and
@@ -871,6 +871,15 @@ class General extends Component
$this->syncData(toModel: true);
$otherApplication = $this->application->isDirty(['docker_registry_image_name', 'build_pack'])
? $this->application->otherApplicationBuildingSameImage()
: null;
if ($otherApplication) {
$this->addError('dockerRegistryImageName', $this->application->sharedImageNameMessage($otherApplication));
return;
}
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
}
+24
View File
@@ -1107,6 +1107,30 @@ class Application extends BaseModel
return data_get($this, 'environment.project.team');
}
/**
* Another application of the team that builds and pushes an image with the same Docker image name.
* Both push the same tags (the commit SHA, `latest`, `pr-<id>`), so one application can run the image of the other.
*/
public function otherApplicationBuildingSameImage(): ?Application
{
$buildPacksWithoutPushedImage = ['dockerimage', 'dockercompose'];
if (blank($this->docker_registry_image_name) || in_array($this->build_pack, $buildPacksWithoutPushedImage, true)) {
return null;
}
return Application::query()
->whereRelation('environment.project.team', 'id', $this->team()->id)
->when($this->exists, fn ($query) => $query->whereKeyNot($this->id))
->where('docker_registry_image_name', $this->docker_registry_image_name)
->whereNotIn('build_pack', $buildPacksWithoutPushedImage)
->first();
}
public function sharedImageNameMessage(Application $otherApplication): string
{
return "The application {$otherApplication->name} also builds the Docker image {$this->docker_registry_image_name}. Both push the same image tags, so one application can run the image of the other. Set a different Docker image name for each application in the General settings.";
}
public function serviceType()
{
$found = str(collect(SPECIFIC_SERVICES)->filter(function ($service) {
@@ -0,0 +1,180 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], []));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]);
});
function makeRegistryImageApplication(Environment $environment, StandaloneDocker $destination, array $attributes = []): Application
{
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'nixpacks',
'docker_registry_image_name' => 'ghcr.io/acme/monorepo',
...$attributes,
]);
}
function runSharedRegistryImageGuard(Application $application): void
{
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$reflection->getProperty('application')->setValue($job, $application);
$reflection->getMethod('ensureRegistryImageIsNotShared')->invoke($job);
}
describe('deployment guard', function () {
test('fails when another building application of the team pushes the same image name', function () {
makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']);
$server = makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'server', 'build_pack' => 'dockerfile']);
expect(fn () => runSharedRegistryImageGuard($server))
->toThrow(DeploymentException::class, 'The application web also builds the Docker image ghcr.io/acme/monorepo');
});
test('allows unique image names', function () {
makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/web']);
$server = makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/server']);
runSharedRegistryImageGuard($server);
})->throwsNoExceptions();
test('allows applications that do not push a built image', function (array $other, array $deployed) {
makeRegistryImageApplication($this->environment, $this->destination, $other);
$application = makeRegistryImageApplication($this->environment, $this->destination, $deployed);
runSharedRegistryImageGuard($application);
})->with([
'other runs the image' => [['build_pack' => 'dockerimage'], []],
'other is docker compose' => [['build_pack' => 'dockercompose'], []],
'deployed runs the image' => [[], ['build_pack' => 'dockerimage']],
'no image name' => [['docker_registry_image_name' => null], ['docker_registry_image_name' => null]],
])->throwsNoExceptions();
test('ignores applications of other teams', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first() ?? Environment::factory()->create(['project_id' => $otherProject->id]);
makeRegistryImageApplication($otherEnvironment, $otherDestination);
$application = makeRegistryImageApplication($this->environment, $this->destination);
runSharedRegistryImageGuard($application);
})->throwsNoExceptions();
});
test('the general form rejects an image name that another building application uses', function () {
makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']);
$server = makeRegistryImageApplication($this->environment, $this->destination, [
'docker_registry_image_name' => 'ghcr.io/acme/server',
'ports_exposes' => '3000',
'base_directory' => '/',
'redirect' => 'both',
'static_image' => 'nginx:alpine',
'is_http_basic_auth_enabled' => false,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Livewire::test(General::class, ['application' => $server])
->set('dockerRegistryImageName', 'ghcr.io/acme/monorepo')
->call('submit')
->assertHasErrors('dockerRegistryImageName');
expect($server->fresh()->docker_registry_image_name)->toBe('ghcr.io/acme/server');
});
describe('API', function () {
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => true]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'shared-image-name-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->apiHeaders = ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken];
$this->server = Server::query()->whereKey($this->destination->server_id)->firstOrFail();
$this->project = Project::query()->whereKey($this->environment->project_id)->firstOrFail();
makeRegistryImageApplication($this->environment, $this->destination, ['name' => 'web']);
});
test('update rejects an image name that another building application uses', function () {
$application = makeRegistryImageApplication($this->environment, $this->destination, ['docker_registry_image_name' => 'ghcr.io/acme/server']);
$this->withHeaders($this->apiHeaders)
->patchJson("/api/v1/applications/{$application->uuid}", ['docker_registry_image_name' => 'ghcr.io/acme/monorepo'])
->assertUnprocessable()
->assertJsonPath('errors.docker_registry_image_name.0', fn (string $message) => str_contains($message, 'The application web also builds'));
expect($application->fresh()->docker_registry_image_name)->toBe('ghcr.io/acme/server');
});
test('update accepts other changes of an application that already shares its image name', function () {
$application = makeRegistryImageApplication($this->environment, $this->destination);
$this->withHeaders($this->apiHeaders)
->patchJson("/api/v1/applications/{$application->uuid}", ['description' => 'api'])
->assertOk();
});
test('create rejects an image name that another building application uses', function () {
$count = Application::count();
$this->withHeaders($this->apiHeaders)
->postJson('/api/v1/applications/dockerfile', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"),
'docker_registry_image_name' => 'ghcr.io/acme/monorepo',
'autogenerate_domain' => false,
])
->assertUnprocessable()
->assertJsonValidationErrors('docker_registry_image_name');
expect(Application::count())->toBe($count);
});
test('create accepts a unique image name', function () {
$this->withHeaders($this->apiHeaders)
->postJson('/api/v1/applications/dockerfile', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80"),
'docker_registry_image_name' => 'ghcr.io/acme/server',
'autogenerate_domain' => false,
])
->assertCreated();
});
});