From 7466da12a215eea1e88e9127aecb357c343d37fb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:30:26 +0200 Subject: [PATCH] fix(git): accept ssh:// repository URLs with custom ports The repository URL rule rejected ssh://user@host:port/path, so deploy-key resources on servers with a custom SSH port needed a manual URL edit after creation. Accept ssh:// URLs and keep them unchanged for deploy keys so the port reaches the clone. Rename scpStyleGitUrlToHttps to sshGitUrlToHttps and let it convert ssh:// URLs too, so public flows, repository links and GitHub App repository slugs handle them. Closes #12161 Co-Authored-By: Claude Opus 5.5 --- .../Api/ApplicationsController.php | 2 +- .../New/GithubPrivateRepositoryDeployKey.php | 9 ++++++++ .../Project/New/PublicGitRepository.php | 2 +- app/Models/Application.php | 14 +++++------ app/Rules/ValidGitRepositoryUrl.php | 17 ++++++++++++-- bootstrap/helpers/shared.php | 13 ++++++++--- .../Api/PublicApplicationSshUrlApiTest.php | 9 +++++--- ...eyApplicationCreationAuthorizationTest.php | 14 +++++++++++ .../Feature/PublicGitRepositorySshUrlTest.php | 8 +++++++ tests/Unit/ApplicationGitCommitLinkTest.php | 8 ++++++- tests/Unit/ScpStyleGitUrlTest.php | 23 +++++++++++++++---- tests/Unit/ValidGitRepositoryUrlTest.php | 10 ++++++++ 12 files changed, 105 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 973a119354..c2891c1321 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1547,7 +1547,7 @@ class ApplicationsController extends Controller $application->domain_port_overrides = $domainPortOverrides; } $gitRepository = $application->git_repository; - $httpsRepository = scpStyleGitUrlToHttps($gitRepository); + $httpsRepository = sshGitUrlToHttps($gitRepository); if (is_string($httpsRepository)) { $gitRepository = $httpsRepository; $application->git_repository = $httpsRepository; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 6af782d1cc..70834564a2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -229,6 +229,15 @@ class GithubPrivateRepositoryDeployKey extends Component return; } + // Keep ssh:// URLs as-is so a custom SSH port is preserved for deployments. + if (str_starts_with($this->repository_url, 'ssh://')) { + $this->git_host = parse_url($this->repository_url, PHP_URL_HOST); + $this->git_repository = $this->repository_url; + $this->git_source = 'other'; + + return; + } + $this->repository_url_parsed = Url::fromString($this->repository_url); $this->git_host = $this->repository_url_parsed->getHost(); $this->git_repository = $this->repository_url_parsed->getSegment(1).'/'.$this->repository_url_parsed->getSegment(2); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a44aa050d9..747bbaa9cd 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -137,7 +137,7 @@ class PublicGitRepository extends Component throw new \RuntimeException('Invalid repository URL: '.$validator->errors()->first('repository_url')); } - $httpsRepositoryUrl = scpStyleGitUrlToHttps($this->repository_url); + $httpsRepositoryUrl = sshGitUrlToHttps($this->repository_url); if (is_string($httpsRepositoryUrl)) { $this->repository_url = $httpsRepositoryUrl; } diff --git a/app/Models/Application.php b/app/Models/Application.php index 5209c3f812..bc64dc067f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -731,7 +731,7 @@ class Application extends BaseModel return "{$this->source->html_url}/{$this->git_repository}/tree/{$this->git_branch}{$base_dir}"; } - $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + $httpsRepository = $this->httpsUrlFromSshGitRepository(); if (is_string($httpsRepository)) { if (str($this->git_repository)->contains('bitbucket')) { return "{$httpsRepository}/src/{$this->git_branch}{$base_dir}"; @@ -752,7 +752,7 @@ class Application extends BaseModel if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/settings/hooks"; } - $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + $httpsRepository = $this->httpsUrlFromSshGitRepository(); if (is_string($httpsRepository)) { return "{$httpsRepository}/settings/hooks"; } @@ -769,7 +769,7 @@ class Application extends BaseModel if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}"; } - $httpsRepository = $this->httpsUrlFromScpStyleGitRepository(); + $httpsRepository = $this->httpsUrlFromSshGitRepository(); if (is_string($httpsRepository)) { return "{$httpsRepository}/commits/{$this->git_branch}"; } @@ -790,11 +790,9 @@ class Application extends BaseModel } $git_repository = $this->git_repository; - $httpsRepository = scpStyleGitUrlToHttps($git_repository); + $httpsRepository = sshGitUrlToHttps($git_repository); if (is_string($httpsRepository)) { $git_repository = $httpsRepository; - } elseif (str($this->git_repository)->startsWith('ssh://')) { - $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } if (! filter_var($git_repository, FILTER_VALIDATE_URL) || ! in_array(parse_url($git_repository, PHP_URL_SCHEME), ['http', 'https'], true)) { @@ -809,9 +807,9 @@ class Application extends BaseModel return $url->__toString(); } - private function httpsUrlFromScpStyleGitRepository(): ?string + private function httpsUrlFromSshGitRepository(): ?string { - $httpsRepository = scpStyleGitUrlToHttps($this->git_repository); + $httpsRepository = sshGitUrlToHttps($this->git_repository); if (! is_string($httpsRepository)) { return null; diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index 9a04409046..8b2d484e54 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -29,7 +29,7 @@ class ValidGitRepositoryUrl implements ValidationRule // Brackets are only allowed around an IPv6 host, so check the rest of the URL without it. $valueWithoutIpv6Host = preg_replace_callback( - '/^((?:https?|git):\/\/|[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/', + '/^((?:https?|git):\/\/|(?:ssh:\/\/)?[a-zA-Z0-9._-]+@)\[([0-9A-Fa-f:.]+)\]/', fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0], $value, ); @@ -96,6 +96,19 @@ class ValidGitRepositoryUrl implements ValidationRule if ($scp === null || preg_match('/^(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { $fail('The :attribute is not a valid SSH repository URL.'); + return; + } + } elseif (str_starts_with($value, 'ssh://')) { + if (! $this->allowSSH) { + $fail('SSH URLs are not allowed.'); + + return; + } + + // Validate ssh:// URL format (ssh://user@host[:port]/user/repo.git) + if (! preg_match('/^ssh:\/\/[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+)(:[0-9]{1,5})?\/[a-zA-Z0-9\-_\/.~]+$/', $value)) { + $fail('The :attribute is not a valid SSH repository URL.'); + return; } } elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) { @@ -160,7 +173,7 @@ class ValidGitRepositoryUrl implements ValidationRule return; } } else { - $fail('The :attribute must start with https://, http://, git://, or be an SSH URL (user@host:path).'); + $fail('The :attribute must start with https://, http://, git://, ssh://, or be an SSH URL (user@host:path).'); return; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 0b2108bfb9..198c2b8589 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4816,8 +4816,15 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array ]; } -function scpStyleGitUrlToHttps(?string $gitRepository): ?string +function sshGitUrlToHttps(?string $gitRepository): ?string { + if (is_string($gitRepository) && str_starts_with($gitRepository, 'ssh://')) { + $host = parse_url($gitRepository, PHP_URL_HOST); + $path = ltrim((string) parse_url($gitRepository, PHP_URL_PATH), '/'); + + return filled($host) && $path !== '' ? "https://{$host}/{$path}" : null; + } + $parts = parseScpStyleGitUrl($gitRepository); if ($parts === null) { @@ -4833,8 +4840,8 @@ function gitRepositorySlug(?string $gitRepository): string return ''; } - if (($scp = parseScpStyleGitUrl($gitRepository)) !== null) { - $gitRepository = $scp['path']; + if (($httpsRepository = sshGitUrlToHttps($gitRepository)) !== null) { + $gitRepository = (string) parse_url($httpsRepository, PHP_URL_PATH); } elseif (str($gitRepository)->startsWith('http') || str($gitRepository)->contains('github.com')) { $gitRepository = str($gitRepository)->replace('https://', '')->replace('http://', '')->replace('github.com/', ''); } diff --git a/tests/Feature/Api/PublicApplicationSshUrlApiTest.php b/tests/Feature/Api/PublicApplicationSshUrlApiTest.php index 03c18ab48b..0aa3e76eeb 100644 --- a/tests/Feature/Api/PublicApplicationSshUrlApiTest.php +++ b/tests/Feature/Api/PublicApplicationSshUrlApiTest.php @@ -30,7 +30,7 @@ beforeEach(function () { $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); -test('public application api converts scp-style ssh urls to https', function () { +test('public application api converts ssh urls to https', function (string $gitRepository) { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', @@ -38,7 +38,7 @@ test('public application api converts scp-style ssh urls to https', function () 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'server_uuid' => $this->server->uuid, - 'git_repository' => 'custom-user@git.example.com:2222/organization/repository.git', + 'git_repository' => $gitRepository, 'git_branch' => 'main', 'build_pack' => 'nixpacks', 'ports_exposes' => '3000', @@ -50,4 +50,7 @@ test('public application api converts scp-style ssh urls to https', function () $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); expect($application->git_repository)->toBe('https://git.example.com/organization/repository.git'); -}); +})->with([ + 'scp-style with port' => 'custom-user@git.example.com:2222/organization/repository.git', + 'ssh scheme with port' => 'ssh://custom-user@git.example.com:2222/organization/repository.git', +]); diff --git a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php index 62cf02ef3b..1146c0cbf5 100644 --- a/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php +++ b/tests/Feature/Authorization/DeployKeyApplicationCreationAuthorizationTest.php @@ -85,6 +85,20 @@ test('admin can select and create an application with a key owned by the current expect(Application::query()->sole()->private_key_id)->toBe($this->privateKey->id); }); +test('deploy key application keeps an ssh url with a custom port', function () { + actAsDeployKeyUser($this->admin, $this->team); + + deployKeyComponent() + ->call('setPrivateKey', $this->privateKey->id) + ->set('repository_url', 'ssh://git@gitea.example.local:2222/myuser/my-repository.git') + ->set('branch', 'main') + ->call('submit') + ->assertHasNoErrors(); + + expect(Application::query()->sole()->git_repository) + ->toBe('ssh://git@gitea.example.local:2222/myuser/my-repository.git'); +}); + test('member cannot invoke the deploy key selection action directly', function () { actAsDeployKeyUser($this->member, $this->team); $this->withoutExceptionHandling(); diff --git a/tests/Feature/PublicGitRepositorySshUrlTest.php b/tests/Feature/PublicGitRepositorySshUrlTest.php index 80df73a9f6..6822925105 100644 --- a/tests/Feature/PublicGitRepositorySshUrlTest.php +++ b/tests/Feature/PublicGitRepositorySshUrlTest.php @@ -32,3 +32,11 @@ test('strips custom ports when converting scp-style ssh urls to https', function ->assertSet('repository_url', 'https://git.example.com/organization/repository.git') ->assertSet('branchFound', true); }); + +test('converts ssh scheme urls with custom ports to https', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('repository_url', 'ssh://git@gitea.example.com:2222/organization/repository.git') + ->call('loadBranch') + ->assertSet('repository_url', 'https://gitea.example.com/organization/repository.git') + ->assertSet('branchFound', true); +}); diff --git a/tests/Unit/ApplicationGitCommitLinkTest.php b/tests/Unit/ApplicationGitCommitLinkTest.php index 7d9bb2b8ca..55eaaf3da5 100644 --- a/tests/Unit/ApplicationGitCommitLinkTest.php +++ b/tests/Unit/ApplicationGitCommitLinkTest.php @@ -47,7 +47,7 @@ it('does not generate commit links from incomplete repository URLs', function (s 'git protocol' => 'git://git.example.com/coollabsio/coolify.git', ]); -it('converts scp-style remotes with generic usernames into https repository links', function (string $repository, string $expectedBranch, string $expectedCommits, string $expectedWebhook) { +it('converts ssh remotes with generic usernames into https repository links', function (string $repository, string $expectedBranch, string $expectedCommits, string $expectedWebhook) { $application = new Application; $application->setRelation('source', null); $application->git_repository = $repository; @@ -76,4 +76,10 @@ it('converts scp-style remotes with generic usernames into https repository link 'https://git.example.com/organization/repository/commits/main', 'https://git.example.com/organization/repository/settings/hooks', ], + 'ssh scheme with port' => [ + 'ssh://git@gitea.example.com:2222/organization/repository.git', + 'https://gitea.example.com/organization/repository/tree/main/', + 'https://gitea.example.com/organization/repository/commits/main', + 'https://gitea.example.com/organization/repository/settings/hooks', + ], ]); diff --git a/tests/Unit/ScpStyleGitUrlTest.php b/tests/Unit/ScpStyleGitUrlTest.php index 7e81484f9b..2c7372186b 100644 --- a/tests/Unit/ScpStyleGitUrlTest.php +++ b/tests/Unit/ScpStyleGitUrlTest.php @@ -50,8 +50,8 @@ it('parses scp-style ssh git urls including custom usernames and ports', functio ], ]); -it('converts scp-style ssh git urls to https without embedding custom ports in the path', function (string $url, string $expected) { - expect(scpStyleGitUrlToHttps($url))->toBe($expected); +it('converts ssh git urls to https without embedding custom ports in the path', function (string $url, string $expected) { + expect(sshGitUrlToHttps($url))->toBe($expected); })->with([ 'git username' => [ 'git@github.com:organization/repository.git', @@ -69,19 +69,30 @@ it('converts scp-style ssh git urls to https without embedding custom ports in t 'git@[fd00::5]:2222/organization/repository.git', 'https://[fd00::5]/organization/repository.git', ], + 'ssh scheme' => [ + 'ssh://git@github.com/organization/repository.git', + 'https://github.com/organization/repository.git', + ], + 'ssh scheme with port' => [ + 'ssh://git@gitea.example.com:2222/organization/repository.git', + 'https://gitea.example.com/organization/repository.git', + ], + 'ssh scheme with IPv6 host and port' => [ + 'ssh://git@[fd00::5]:2222/organization/repository.git', + 'https://[fd00::5]/organization/repository.git', + ], ]); it('rejects non-scp-style git urls', function (string $url) { expect(parseScpStyleGitUrl($url))->toBeNull() - ->and(scpStyleGitUrlToHttps($url))->toBeNull(); + ->and(sshGitUrlToHttps($url))->toBeNull(); })->with([ 'https' => 'https://github.com/organization/repository.git', 'email without path' => 'custom-user@git.example.com', - 'ssh scheme' => 'ssh://git@github.com/organization/repository.git', 'empty' => '', ]); -it('normalizes github app repository slugs from scp-style ssh urls', function (string $url, string $expected) { +it('normalizes github app repository slugs from ssh urls', function (string $url, string $expected) { expect(gitRepositorySlug($url))->toBe($expected); })->with([ 'https' => ['https://github.com/organization/repository.git', 'organization/repository'], @@ -89,4 +100,6 @@ it('normalizes github app repository slugs from scp-style ssh urls', function (s 'git username' => ['git@github.com:organization/repository.git', 'organization/repository'], 'custom username' => ['custom-user@git.example.com:organization/repository.git', 'organization/repository'], 'custom username and port' => ['custom-user@git.example.com:2222/organization/repository.git', 'organization/repository'], + 'ssh scheme' => ['ssh://git@github.com/organization/repository.git', 'organization/repository'], + 'ssh scheme with port' => ['ssh://git@ghe.example.com:2222/organization/repository.git', 'organization/repository'], ]); diff --git a/tests/Unit/ValidGitRepositoryUrlTest.php b/tests/Unit/ValidGitRepositoryUrlTest.php index f76a349c79..6ffa7e4a01 100644 --- a/tests/Unit/ValidGitRepositoryUrlTest.php +++ b/tests/Unit/ValidGitRepositoryUrlTest.php @@ -110,6 +110,10 @@ it('validates SSH URLs when allowed', function () { 'custom-user@git.example.com:organization/repository.git', 'custom-user@git.example.com:2222/organization/repository.git', 'enterprise-user@enterprise.ghe.com:organization/repository.git', + 'ssh://git@gitea.example.local:2222/myuser/my-repository.git', + 'ssh://git@github.com/user/repo.git', + 'ssh://git@192.168.1.10:22/~user/repo.git', + 'ssh://git@[2001:db8::10]:2222/group/project.git', ]; foreach ($validUrls as $url) { @@ -133,6 +137,7 @@ it('rejects SSH URLs when not allowed', function () { 'git@github.com:user/repo.git', 'git@gitlab.com:user/repo.git', 'custom-user@git.example.com:organization/repository.git', + 'ssh://git@gitea.example.com:2222/organization/repository.git', ]; foreach ($invalidUrls as $url) { @@ -371,6 +376,11 @@ it('rejects invalid URL formats', function () { 'ftp://github.com/user/repo', 'file:///path/to/repo', 'ssh://github.com/user/repo', + 'ssh://git@github.com', + 'ssh://git@github.com:2222', + 'ssh://git@github.com:port/user/repo.git', + 'ssh://git@github.com:2222:user/repo.git', + 'ssh://git@[not-an-ip]:2222/user/repo.git', 'https://', 'http://', 'git@',