From e7483f591f59d1fd2064d5524e8f5d40d942de64 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:54:14 +0200 Subject: [PATCH] fix(deployments): scope submodule git credentials per command Use per-command git config for GitHub App HTTPS credentials so private submodules authenticate without persisting global git config. Preserve configured git options for checkout, fetch, submodule, and LFS commands, and cover GitLab PR submodule checkout with tests. --- app/Models/Application.php | 40 +++++++-------- .../GitHubAppSubmoduleCredentialsTest.php | 49 +++++++++++++++++++ tests/Unit/GitSubmoduleCredentialTest.php | 46 +++++++++++++++++ 3 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/GitHubAppSubmoduleCredentialsTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index 6cbba9cc2..3905281d8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1279,11 +1279,12 @@ public function dirOnServer() return application_configuration_dir()."/{$this->uuid}"; } - public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null) + public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null) { $baseDir = $this->generateBaseDir($deployment_uuid); $escapedBaseDir = escapeshellarg($baseDir); $isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false; + $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git'; $resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand; $sshCommand = $resolvedGitSshCommand @@ -1301,9 +1302,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ // If shallow clone is enabled and we need a specific commit, // we need to fetch that specific commit with depth=1 if ($isShallowCloneEnabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } else { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1"; } } if ($this->settings->is_git_submodules_enabled) { @@ -1314,10 +1315,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_ } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi"; + $git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi"; } if ($this->settings->is_git_lfs_enabled) { - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull"; + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull"; } return $git_clone_command; @@ -1559,26 +1560,23 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $github_access_token = generateGithubInstallationToken($this->source); $encodedToken = rawurlencode($github_access_token); - // Configure git to rewrite URLs with the token so submodules on the same host authenticate correctly. - $escapedTokenUrl = escapeshellarg("{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/"); - $escapedHostUrl = escapeshellarg("{$source_html_url_scheme}://{$source_html_url_host}/"); - $gitConfigCommand = "git config --global url.{$escapedTokenUrl}.insteadOf {$escapedHostUrl}"; + // Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials. + $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/"); + $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command); if ($exec_in_docker) { - $commands->push(executeInDocker($deployment_uuid, $gitConfigCommand)); $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } else { - $commands->push($gitConfigCommand); $repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}"; $escapedRepoUrl = escapeshellarg($repoUrl); $git_clone_command = "{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}"; $fullRepoUrl = $repoUrl; } if (! $only_checkout) { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption); } if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, $git_clone_command)); @@ -1589,7 +1587,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req if ($pull_request_id !== 0) { $branch = "pull/{$pull_request_id}/head:$pr_branch_name"; - $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name); + $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null); $escapedPrBranch = escapeshellarg($branch); if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")); @@ -1614,12 +1612,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $private_key = base64_encode($private_key); $gitlabPort = $gitlabSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); - $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\""; - $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; + $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa"; + $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; + $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}"; if ($only_checkout) { $git_clone_command = $git_clone_command_base; } else { - $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand); + $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand); } if ($exec_in_docker) { $commands = collect([ @@ -1642,7 +1641,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req } else { $commands->push("echo 'Checking out $branch'"); } - $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name); + $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand); } if ($exec_in_docker) { @@ -2024,14 +2023,15 @@ public function fqdns(): Attribute ); } - protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null): string + protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string { $escapedTarget = escapeshellarg($target); - $command = "git checkout {$escapedTarget}"; + $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git'; + $command = "{$gitCommand} checkout {$escapedTarget}"; if ($this->settings->is_git_submodules_enabled) { $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; - $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" git submodule update --init --recursive"; + $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive"; } return $command; diff --git a/tests/Unit/GitHubAppSubmoduleCredentialsTest.php b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php new file mode 100644 index 000000000..48f18d2a5 --- /dev/null +++ b/tests/Unit/GitHubAppSubmoduleCredentialsTest.php @@ -0,0 +1,49 @@ +forceFill([ + 'uuid' => 'test-app-uuid', + 'git_repository' => 'coollabsio/private-app', + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + $application->setRelation('settings', $settings); + + $source = new GithubApp; + $source->forceFill([ + 'html_url' => 'https://github.com', + 'api_url' => 'https://api.github.com', + 'is_public' => false, + ]); + $application->setRelation('source', $source); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + exec_in_docker: false, + ); + + expect($result['commands']) + ->not->toContain('git config --global') + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' clone --recurse-submodules -b 'main'") + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule sync") + ->toContain("git -c 'url.https://x-access-token:review%20token%2Fwith%2Bsymbols@github.com/.insteadOf=https://github.com/' submodule update --init --recursive"); + }); +} diff --git a/tests/Unit/GitSubmoduleCredentialTest.php b/tests/Unit/GitSubmoduleCredentialTest.php index 1adaf735f..5ac5c501a 100644 --- a/tests/Unit/GitSubmoduleCredentialTest.php +++ b/tests/Unit/GitSubmoduleCredentialTest.php @@ -2,6 +2,8 @@ use App\Models\Application; use App\Models\ApplicationSetting; +use App\Models\GitlabApp; +use App\Models\PrivateKey; describe('Git submodule credential propagation', function () { beforeEach(function () { @@ -119,4 +121,48 @@ ->toContain("git checkout 'main'") ->not->toContain('submodule'); }); + + test('generateGitImportCommands uses GitLab private key for PR submodule checkout', function () { + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = true; + $settings->is_git_lfs_enabled = false; + + $privateKey = Mockery::mock(PrivateKey::class)->makePartial(); + $privateKey->shouldReceive('getAttribute')->with('private_key')->andReturn('fake-private-key'); + + $gitlabSource = Mockery::mock(GitlabApp::class)->makePartial(); + $gitlabSource->shouldReceive('getMorphClass')->andReturn(GitlabApp::class); + $gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey); + $gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22); + $gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com'); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->git_branch = 'main'; + $application->git_commit_sha = 'HEAD'; + $application->setRelation('settings', $settings); + $application->source = $gitlabSource; + $application->shouldReceive('deploymentType')->andReturn('source'); + $application->shouldReceive('customRepository')->andReturn([ + 'repository' => 'git@gitlab.com:user/repo.git', + 'port' => 22, + ]); + $application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-uuid', + pull_request_id: 123, + git_type: 'gitlab', + exec_in_docker: false, + ); + + $sshCommand = 'ssh -o ConnectTimeout=30 -p 22 -o Port=22 -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa'; + + expect($result['commands']) + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git fetch origin merge-requests/123/head:pr-123-coolify') + ->toContain("git checkout 'pr-123-coolify'") + ->toContain('GIT_SSH_COMMAND="'.$sshCommand.'" git submodule update --init --recursive') + ->not->toContain('GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" git submodule update --init --recursive'); + }); + });