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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-15 22:30:26 +02:00
co-authored by Claude Opus 5.5
parent c82cac626f
commit 7466da12a2
12 changed files with 105 additions and 24 deletions
@@ -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;
@@ -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);
@@ -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;
}
+6 -8
View File
@@ -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;
+15 -2
View File
@@ -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;
}
+10 -3
View File
@@ -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/', '');
}
@@ -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',
]);
@@ -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();
@@ -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);
});
+7 -1
View File
@@ -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',
],
]);
+18 -5
View File
@@ -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'],
]);
+10
View File
@@ -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@',