diff --git a/app/Models/Application.php b/app/Models/Application.php index 45e6eb3615..22a8a67041 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -2271,37 +2271,40 @@ class Application extends BaseModel } /** - * Commands that check out only the Compose file on the server and print it. They run on the - * server itself, not in a helper container, so the checkout uses an absolute folder in /tmp. + * Commands that clone the repository on the server and print the Compose file. They run on the + * server itself, not in a helper container, so the clone uses an absolute folder in /tmp. + * + * The file is read from the Git objects, not from a working tree, so no checkout is needed. Only + * regular files are loaded; a symbolic link (mode 120000) gets a clear error message. * * @return Collection */ - private function composeFileReadCommands(string $uuid, string $gitVersion): Collection + private function composeFileReadCommands(string $uuid): Collection { $checkoutDir = "/tmp/{$uuid}/checkout"; ['command' => $cloneCommand, 'git' => $git] = $this->serverCheckout($uuid, $checkoutDir); - $workdir = rtrim($this->base_directory, '/'); - $fileList = collect([".{$workdir}{$this->docker_compose_location}"]); - $composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}"); - $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; - $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; - - $isConeMode = version_compare($gitVersion, '2.35.1', '>='); - if (! $isConeMode) { - $fileList = $fileList->map(function ($file) { - $parts = explode('/', trim($file, '.')); - $paths = collect(); - $currentPath = ''; - foreach ($parts as $part) { - $currentPath .= ($currentPath ? '/' : '').$part; - if (str($currentPath)->isNotEmpty()) { - $paths->push($currentPath); - } - } - - return $paths; - })->flatten()->unique()->values(); - } + $composeFile = ltrim(rtrim($this->base_directory, '/').$this->docker_compose_location, '/'); + $submoduleCommands = $this->composeFileSubmoduleCommands($git, $composeFile); + // Go into the submodule that contains the Compose file, and read the file from the objects of that submodule. + $enterSubmodule = $submoduleCommands === [] ? '' : implode(' ', [ + 'while sub=$(git ls-tree -r HEAD | while read -r mode type object path; do', + 'if [ "$mode" = 160000 ]; then case "$file" in "$path"/*) echo "$path"; break ;; esac; fi; done) && [ -n "$sub" ];', + 'do cd "$sub" || exit 1; file="${file#"$sub"/}"; done;', + ]); + $readComposeFile = implode(' ', [ + 'file='.escapeshellarg($composeFile).';', + $enterSubmodule, + 'git ls-tree HEAD -- "$file" | {', + 'read -r mode type object path;', + 'case "$mode" in', + '100644|100755) ;;', + "120000) echo 'The Docker Compose file is a symbolic link.' >&2; exit 1 ;;", + "*) echo 'No such file' >&2; exit 1 ;;", + 'esac;', + 'if [ "$(git cat-file -s "$object")" -gt '.self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__';", + 'else git cat-file blob "$object"; fi;', + '}', + ]); return collect([ "rm -rf /tmp/{$uuid}", @@ -2310,17 +2313,14 @@ class Application extends BaseModel // One sh -c line: the non-root sudo parser would turn `&& if ...; then` into invalid `&& sudo if`. 'sh -c '.escapeshellarg($cloneCommand), "cd {$checkoutDir}", - $isConeMode ? 'git sparse-checkout init --cone' : 'git sparse-checkout init', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - ...$this->composeFileSubmoduleCommands($git, ltrim("{$workdir}{$this->docker_compose_location}", '/')), - $readComposeFile, + ...$submoduleCommands, + 'sh -c '.escapeshellarg($readComposeFile), ]); } /** - * A sparse checkout has no submodules. If the Compose file is inside a submodule, this clones only that - * submodule, with the credentials of the clone. + * The clone has no checkout and no submodules. If the Compose file is inside a submodule, this fills the + * index (without a working tree) and clones only that submodule, with the credentials of the clone. * * @return list */ @@ -2334,7 +2334,7 @@ class Application extends BaseModel $escapedComposeFile = escapeshellarg($composeFile); // One sh -c line: the non-root sudo parser cannot change commands inside a loop. - return ['sh -c '.escapeshellarg( + return ['git read-tree HEAD', 'sh -c '.escapeshellarg( 'git ls-files -s | while read -r mode object stage path; do ' ."if [ \"\$mode\" = 160000 ]; then case {$escapedComposeFile} in \"\$path\"/*) " ."{$git} submodule update --init --recursive{$depthFlag} -- \"\$path\" >/dev/null || exit 1 ;; esac; fi; done" @@ -2362,8 +2362,7 @@ class Application extends BaseModel if (! $gitRemoteStatus['is_accessible']) { throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'.$this->gitFailureReason('Failed to read Git source.', (string) $gitRemoteStatus['error'])); } - $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); - $commands = $this->composeFileReadCommands($uuid, (string) str($getGitVersion)->explode(' ')->last()); + $commands = $this->composeFileReadCommands($uuid); try { $composeFileContent = instant_remote_process($commands, $this->destination->server); if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) { @@ -2375,6 +2374,9 @@ class Application extends BaseModel $this->base_directory = $initialBaseDirectory; $this->save(); + if (str($e->getMessage())->contains('The Docker Compose file is a symbolic link.')) { + throw new RuntimeException("Docker Compose file at $workdir$composeFile (branch: {$this->git_branch}) is a symbolic link. Coolify reads only regular files from the repository. Commit the file itself instead of a link to it."); + } if (str($e->getMessage())->contains('No such file')) { throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } diff --git a/tests/Feature/ApplicationComposeInjectionValidationTest.php b/tests/Feature/ApplicationComposeInjectionValidationTest.php index 5e004fd232..86a13682a3 100644 --- a/tests/Feature/ApplicationComposeInjectionValidationTest.php +++ b/tests/Feature/ApplicationComposeInjectionValidationTest.php @@ -76,10 +76,7 @@ function fakeRepositoryCompose(string $compose): void { Process::fake(function ($process) use ($compose) { $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; - if (str_contains($command, 'git --version')) { - return Process::result(output: 'git version 2.43.0'); - } - if (str_contains($command, 'head -c')) { + if (str_contains($command, 'git cat-file blob')) { return Process::result(output: $compose); } diff --git a/tests/Feature/ComposeContentVolumeConfinementTest.php b/tests/Feature/ComposeContentVolumeConfinementTest.php index b1e72ccf1b..9df9ac2466 100644 --- a/tests/Feature/ComposeContentVolumeConfinementTest.php +++ b/tests/Feature/ComposeContentVolumeConfinementTest.php @@ -360,8 +360,7 @@ function contentHostPathFakeGitCompose(string $compose): void $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; return Process::result(output: match (true) { - str_contains($command, 'git --version') => 'git version 2.43.0', - str_contains($command, 'head -c') => $compose, + str_contains($command, 'git cat-file blob') => $compose, default => '', }); }); diff --git a/tests/Feature/ComposeFileLoadCommandsTest.php b/tests/Feature/ComposeFileLoadCommandsTest.php index 9a0c7e847e..b9ed445fe9 100644 --- a/tests/Feature/ComposeFileLoadCommandsTest.php +++ b/tests/Feature/ComposeFileLoadCommandsTest.php @@ -46,9 +46,8 @@ test('the Compose file loads through the server commands for root and non-root u ]); $application->settings->update(['is_git_submodules_enabled' => true]); $uuid = 'compose-load-'.bin2hex(random_bytes(4)); - $gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last(); - $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid, $gitVersion); + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid); if ($user !== 'root') { $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('getAttribute')->with('user')->andReturn($user); @@ -74,6 +73,72 @@ test('the Compose file loads through the server commands for root and non-root u 'pinned commit' => [true], ]); +/** + * Commits the given symlinks to the repository, pushes them to the bare remote, and runs the + * Compose read commands for an application with the given base directory and Compose file location. + */ +function runComposeReadWithSymlinks(object $test, array $symlinks, string $baseDirectory, string $composeLocation): Process +{ + foreach ($symlinks as $link => $target) { + @mkdir(dirname($test->root.'/src/'.$link), 0755, true); + symlink($target, $test->root.'/src/'.$link); + } + foreach ([ + ['git', 'add', '-A'], + ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'symlinks'], + ['git', 'push', '-q', $test->root.'/repo.git', 'main'], + ] as $command) { + (new Process($command, $test->root.'/src'))->mustRun(); + } + + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'git_repository' => $test->root.'/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'base_directory' => $baseDirectory, + 'docker_compose_location' => $composeLocation, + ]); + $uuid = 'compose-load-'.bin2hex(random_bytes(4)); + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid); + + $process = new Process(['bash', '-se'], env: ['PATH' => $test->root.'/bin:'.getenv('PATH')]); + $process->setInput($commands->implode("\n")."\n"); + $process->run(); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + return $process; +} + +test('a symlinked Compose file is rejected', function (string $target) { + if ($target === 'outside') { + $target = $this->root.'/linked-target.yml'; + } + file_put_contents($this->root.'/linked-target.yml', "services:\n linked:\n environment:\n TOKEN: linked-target-value\n"); + + $process = runComposeReadWithSymlinks($this, ['docker-compose.yml' => $target], '/', '/docker-compose.yml'); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getOutput())->toBe('') + ->and($process->getErrorOutput())->toContain('symbolic link') + ->and($process->getErrorOutput())->not->toContain('linked-target-value'); +})->with([ + 'absolute path' => ['outside'], + 'file in the repository' => ['apps/web/docker-compose.yml'], +]); + +test('a symlinked base directory is not found', function () { + mkdir($this->root.'/linked-app'); + file_put_contents($this->root.'/linked-app/docker-compose.yml', "services:\n linked:\n image: linked-target-value\n"); + + $process = runComposeReadWithSymlinks($this, ['linked' => $this->root.'/linked-app'], '/linked', '/docker-compose.yml'); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getOutput())->toBe('') + ->and($process->getErrorOutput())->toContain('No such file') + ->and($process->getErrorOutput())->not->toContain('linked-target-value'); +}); + test('the Compose file loads from inside a Git submodule', function (string $submodulePath, string $composeLocation, string $user) { $submoduleSource = $this->root.'/infra-src'; $composeDir = substr('infra/templates/wordpress-staging', strlen($submodulePath) + 1); @@ -103,9 +168,8 @@ test('the Compose file loads from inside a Git submodule', function (string $sub ]); $application->settings->update(['is_git_submodules_enabled' => true]); $uuid = 'compose-load-'.bin2hex(random_bytes(4)); - $gitVersion = (string) str((new Process(['git', '--version']))->mustRun()->getOutput())->trim()->explode(' ')->last(); - $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid, $gitVersion); + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid); if ($user !== 'root') { $server = Mockery::mock(Server::class)->makePartial(); $server->shouldReceive('getAttribute')->with('user')->andReturn($user); @@ -135,3 +199,44 @@ test('the Compose file loads from inside a Git submodule', function (string $sub 'root' => ['root'], 'non-root' => ['ubuntu'], ]); + +test('a symlinked Compose file inside a Git submodule is rejected', function () { + $submoduleSource = $this->root.'/infra-src'; + mkdir($submoduleSource); + file_put_contents($this->root.'/linked-target.yml', "services:\n linked:\n image: linked-target-value\n"); + symlink($this->root.'/linked-target.yml', "{$submoduleSource}/docker-compose.yml"); + $gitEnv = ['GIT_CONFIG_COUNT' => '1', 'GIT_CONFIG_KEY_0' => 'protocol.file.allow', 'GIT_CONFIG_VALUE_0' => 'always']; + foreach ([ + [$submoduleSource, ['git', 'init', '-q', '-b', 'main']], + [$submoduleSource, ['git', 'add', '-A']], + [$submoduleSource, ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'infra']], + [$submoduleSource, ['git', 'clone', '-q', '--bare', '.', $this->root.'/infra.git']], + [$this->root.'/src', ['git', 'submodule', '-q', 'add', $this->root.'/infra.git', 'infra']], + [$this->root.'/src', ['git', '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-qm', 'add infra']], + [$this->root.'/src', ['git', 'push', '-q', $this->root.'/repo.git', 'main']], + ] as [$cwd, $command]) { + (new Process($command, $cwd, $gitEnv))->mustRun(); + } + + $application = Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'git_repository' => $this->root.'/repo.git', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + 'base_directory' => '/', + 'docker_compose_location' => '/infra/docker-compose.yml', + ]); + $application->settings->update(['is_git_submodules_enabled' => true]); + $uuid = 'compose-load-'.bin2hex(random_bytes(4)); + $commands = (new ReflectionMethod(Application::class, 'composeFileReadCommands'))->invoke($application->fresh(), $uuid); + + $process = new Process(['bash', '-se'], env: ['PATH' => $this->root.'/bin:'.getenv('PATH')] + $gitEnv); + $process->setInput($commands->implode("\n")."\n"); + $process->run(); + (new Process(['rm', '-rf', "/tmp/{$uuid}"]))->run(); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getOutput())->toBe('') + ->and($process->getErrorOutput())->toContain('symbolic link') + ->and($process->getErrorOutput())->not->toContain('linked-target-value'); +}); diff --git a/tests/Feature/ComposeFileLoadErrorTest.php b/tests/Feature/ComposeFileLoadErrorTest.php index 9ada0c45f7..5513cfe8d4 100644 --- a/tests/Feature/ComposeFileLoadErrorTest.php +++ b/tests/Feature/ComposeFileLoadErrorTest.php @@ -40,12 +40,12 @@ function fakeComposeLoadServer(?string $failingStep, string $errorOutput): void return Process::result(errorOutput: $errorOutput, exitCode: 128); } - return Process::result(output: str_contains($command, 'git --version') ? 'git version 2.43.0' : ''); + return Process::result(output: ''); }); } it('shows and logs why the Compose file could not be read, without credentials', function () { - fakeComposeLoadServer('sparse-checkout', $this->gitError); + fakeComposeLoadServer('git ls-tree', $this->gitError); expect(fn () => $this->application->loadComposeFile()) ->toThrow(function (RuntimeException $exception) { @@ -77,3 +77,12 @@ it('shows and logs why the Git source could not be read, without credentials', f Log::shouldHaveReceived('warning')->withArgs(fn (string $message, array $context) => $message === 'Failed to read Git source.' && ! str_contains(json_encode($context), 'ghs_SECRET123')); }); + +it('tells the user that a symlinked Compose file is not loaded', function () { + $this->application->update(['docker_compose_raw' => "services:\n web:\n image: nginx\n"]); + fakeComposeLoadServer('git ls-tree', 'The Docker Compose file is a symbolic link.'); + + expect(fn () => $this->application->loadComposeFile()) + ->toThrow(RuntimeException::class, 'Docker Compose file at /docker-compose.yml (branch: main) is a symbolic link.'); + expect($this->application->fresh()->docker_compose_raw)->toBe("services:\n web:\n image: nginx\n"); +}); diff --git a/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php b/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php index 2835dc28f1..b58c017626 100644 --- a/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php +++ b/tests/Feature/EnvironmentVariable/RefreshEnvsTargetingTest.php @@ -75,10 +75,7 @@ test('reloading the compose file refreshes only the environment variable list', ]); Process::fake(function ($process) { $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; - if (str_contains($command, 'git --version')) { - return Process::result(output: 'git version 2.43.0'); - } - if (str_contains($command, 'head -c')) { + if (str_contains($command, 'git cat-file blob')) { return Process::result(output: "services:\n web:\n image: nginx:alpine\n"); } diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index f1bea3c744..2dacc7ff28 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -93,11 +93,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func $application->shouldReceive('save')->once(); Process::fake(function ($process) { - if (str_contains($process->command, 'git --version')) { - return Process::result(output: 'git version 2.40.0'); - } - - if (str_contains($process->command, 'wc -c')) { + if (str_contains($process->command, 'git cat-file -s')) { return Process::result(output: '__COOLIFY_COMPOSE_TOO_LARGE__'); } @@ -107,7 +103,7 @@ it('rejects oversized docker compose files loaded from git before parsing', func expect(fn () => $application->loadComposeFile()) ->toThrow(RuntimeException::class, 'Docker Compose file exceeds the 5 MiB size limit.'); expect($application->docker_compose_raw)->toBeNull(); - Process::assertRan(fn ($process) => str_contains($process->command, 'head -c '.(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1))); + Process::assertRan(fn ($process) => str_contains($process->command, '-gt '.Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)); }); it('bounds scheduled task output before storing or notifying', function () { $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); diff --git a/tests/v4/Browser/ApplicationConfigurationTest.php b/tests/v4/Browser/ApplicationConfigurationTest.php index 946904fce6..b390870633 100644 --- a/tests/v4/Browser/ApplicationConfigurationTest.php +++ b/tests/v4/Browser/ApplicationConfigurationTest.php @@ -201,8 +201,7 @@ it('saves application name description and ports from the general form', functio it('shows the unsaved bar for a Compose location edit and saves it with the name', function () { Process::fake(fn ($process) => match (true) { - str_contains($process->command, 'git --version') => Process::result('git version 2.43.0'), - str_contains($process->command, 'head -c') => Process::result("services:\n web:\n image: nginx\n"), + str_contains($process->command, 'git cat-file blob') => Process::result("services:\n web:\n image: nginx\n"), default => Process::result(''), }); $this->application->update([