From 8c4aa7bb00c3049a805822f554593224b49be37e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:31:07 +0200 Subject: [PATCH] feat(git): allow IPv4 and IPv6 addresses in repository URLs Accept HTTP(S), git:// and SCP-style SSH repository URLs whose host is an IP address, including bracketed IPv6 such as git@[2a01:4f8::1]:org/repo.git. The clone runs on the user's deployment server, so the IP block did not protect Coolify, and SSH URLs with IPv4 already passed. Loopback, link-local and other reserved addresses are still rejected as internal hosts. Brackets are allowed only around a valid IPv6 host. --- app/Rules/ValidGitRepositoryUrl.php | 28 +++++-- bootstrap/helpers/shared.php | 2 +- .../Feature/Helpers/ConvertingGitUrlsTest.php | 8 ++ tests/Unit/ScpStyleGitUrlTest.php | 22 +++++ tests/Unit/ValidGitRepositoryUrlTest.php | 83 +++++++++++++++++++ 5 files changed, 133 insertions(+), 10 deletions(-) diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index 29e219bd35..9a04409046 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -12,7 +12,7 @@ class ValidGitRepositoryUrl implements ValidationRule protected bool $allowIP; - public function __construct(bool $allowSSH = true, bool $allowIP = false) + public function __construct(bool $allowSSH = true, bool $allowIP = true) { $this->allowSSH = $allowSSH; $this->allowIP = $allowIP; @@ -27,6 +27,13 @@ class ValidGitRepositoryUrl implements ValidationRule return; } + // 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:.]+)\]/', + fn (array $matches): string => filter_var($matches[2], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? $matches[1].'ipv6-host' : $matches[0], + $value, + ); + // Check for dangerous shell metacharacters that could be used for command injection $dangerousChars = [ ';', '|', '&', '$', '`', '(', ')', '{', '}', @@ -36,7 +43,7 @@ class ValidGitRepositoryUrl implements ValidationRule ]; foreach ($dangerousChars as $char) { - if (str_contains($value, $char)) { + if (str_contains($valueWithoutIpv6Host, $char)) { Log::warning('Git repository URL validation failed - dangerous character', [ 'url' => $value, 'character' => $char, @@ -63,7 +70,7 @@ class ValidGitRepositoryUrl implements ValidationRule ]; foreach ($dangerousPatterns as $pattern) { - if (preg_match($pattern, $value)) { + if (preg_match($pattern, $valueWithoutIpv6Host)) { Log::warning('Git repository URL validation failed - dangerous pattern', [ 'url' => $value, 'pattern' => $pattern, @@ -77,7 +84,7 @@ class ValidGitRepositoryUrl implements ValidationRule } // Validate based on URL type - if (preg_match('/^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+:/', $value)) { + if (preg_match('/^[a-zA-Z0-9._-]+@(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9.-]+):/', $value)) { if (! $this->allowSSH) { $fail('SSH URLs are not allowed.'); @@ -86,7 +93,7 @@ class ValidGitRepositoryUrl implements ValidationRule // Validate scp-style SSH URL format (user@host:user/repo.git) $scp = parseScpStyleGitUrl($value); - if ($scp === null || preg_match('/^[a-zA-Z0-9.-]+$/', $scp['host']) !== 1 || preg_match('/^[a-zA-Z0-9\-_\/.~]+$/', $scp['path']) !== 1) { + 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; @@ -100,9 +107,11 @@ class ValidGitRepositoryUrl implements ValidationRule } $parsed = parse_url($value); + // parse_url() keeps the brackets around an IPv6 host. + $host = trim(strtolower($parsed['host'] ?? ''), '[]'); // Check for IP addresses if not allowed - if (! $this->allowIP && filter_var($parsed['host'] ?? '', FILTER_VALIDATE_IP)) { + if (! $this->allowIP && filter_var($host, FILTER_VALIDATE_IP)) { Log::warning('Git repository URL contains IP address', [ 'url' => $value, 'ip' => request()->ip(), @@ -114,9 +123,10 @@ class ValidGitRepositoryUrl implements ValidationRule } // Check for localhost/internal addresses - $host = strtolower($parsed['host'] ?? ''); $internalHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; - if (in_array($host, $internalHosts) || str_ends_with($host, '.local')) { + $isReservedIp = filter_var($host, FILTER_VALIDATE_IP) !== false + && filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) === false; + if (in_array($host, $internalHosts) || $isReservedIp || str_ends_with($host, '.local')) { Log::warning('Git repository URL points to internal host', [ 'url' => $value, 'host' => $host, @@ -144,7 +154,7 @@ class ValidGitRepositoryUrl implements ValidationRule } } elseif (str_starts_with($value, 'git://')) { // Validate git:// protocol URL (supports both git://host/path and git://host:port/path with tilde) - if (! preg_match('/^git:\/\/[a-zA-Z0-9\.\-]+(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { + if (! preg_match('/^git:\/\/(\[[0-9A-Fa-f:.]+\]|[a-zA-Z0-9\.\-]+)(:[0-9]+)?[:\/][a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid git:// URL.'); return; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 42abc2dcb0..bc707a2327 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4713,7 +4713,7 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array return null; } - if (preg_match('/^(?[A-Za-z0-9._-]+)@(?[^:]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { + if (preg_match('/^(?[A-Za-z0-9._-]+)@(?\[[0-9A-Fa-f:.]+\]|[^:\[\]]+):(?:(?\d+)\/)?(?.+)$/', $gitRepository, $matches) !== 1) { return null; } diff --git a/tests/Feature/Helpers/ConvertingGitUrlsTest.php b/tests/Feature/Helpers/ConvertingGitUrlsTest.php index e3e2549af4..ad764fdf24 100644 --- a/tests/Feature/Helpers/ConvertingGitUrlsTest.php +++ b/tests/Feature/Helpers/ConvertingGitUrlsTest.php @@ -128,3 +128,11 @@ test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function 'port' => '22222', ]); }); + +test('convertGitUrlsForDeployKeyAndIpv6SshUrlWithPort', function () { + $result = convertGitUrl('git@[fd00::5]:2222/organization/repository.git', 'deploy_key', null); + expect($result)->toBe([ + 'repository' => 'git@[fd00::5]:organization/repository.git', + 'port' => '2222', + ]); +}); diff --git a/tests/Unit/ScpStyleGitUrlTest.php b/tests/Unit/ScpStyleGitUrlTest.php index ec95e341cd..7e81484f9b 100644 --- a/tests/Unit/ScpStyleGitUrlTest.php +++ b/tests/Unit/ScpStyleGitUrlTest.php @@ -30,6 +30,24 @@ it('parses scp-style ssh git urls including custom usernames and ports', functio 'path' => 'organization/repository.git', ], ], + 'IPv6 host' => [ + 'git@[2a01:4f8::1]:organization/repository.git', + [ + 'user' => 'git', + 'host' => '[2a01:4f8::1]', + 'port' => null, + 'path' => 'organization/repository.git', + ], + ], + 'IPv6 host and port' => [ + 'git@[fd00::5]:2222/organization/repository.git', + [ + 'user' => 'git', + 'host' => '[fd00::5]', + 'port' => '2222', + 'path' => 'organization/repository.git', + ], + ], ]); it('converts scp-style ssh git urls to https without embedding custom ports in the path', function (string $url, string $expected) { @@ -47,6 +65,10 @@ it('converts scp-style ssh git urls to https without embedding custom ports in t 'custom-user@git.example.com:2222/organization/repository.git', 'https://git.example.com/organization/repository.git', ], + 'IPv6 host and port' => [ + 'git@[fd00::5]:2222/organization/repository.git', + 'https://[fd00::5]/organization/repository.git', + ], ]); it('rejects non-scp-style git urls', function (string $url) { diff --git a/tests/Unit/ValidGitRepositoryUrlTest.php b/tests/Unit/ValidGitRepositoryUrlTest.php index b4bcb465aa..f76a349c79 100644 --- a/tests/Unit/ValidGitRepositoryUrlTest.php +++ b/tests/Unit/ValidGitRepositoryUrlTest.php @@ -241,6 +241,89 @@ it('allows IP addresses when explicitly allowed', function () { } }); +it('allows IP addresses by default', function () { + $rule = new ValidGitRepositoryUrl; + + $validUrls = [ + 'https://192.168.1.10/org/repo.git', + 'http://10.0.0.5:3000/org/repo.git', + 'https://203.0.113.7/org/repo', + ]; + + foreach ($validUrls as $url) { + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("IP address URL should be allowed: {$url}"); + } +}); + +it('rejects loopback and reserved IP addresses even when IP addresses are allowed', function () { + $rule = new ValidGitRepositoryUrl; + + $invalidUrls = [ + 'https://127.0.0.1/org/repo', + 'https://127.0.0.2/org/repo', + 'https://0.0.0.0/org/repo', + 'https://169.254.169.254/org/repo', + ]; + + foreach ($invalidUrls as $url) { + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Reserved IP URL should be rejected: {$url}"); + expect($validator->errors()->first('url'))->toBe('The url cannot point to internal hosts.'); + } +}); + +it('allows IPv6 addresses in every URL format', function () { + $rule = new ValidGitRepositoryUrl; + + $validUrls = [ + 'https://[2a01:4f8::1]/org/repo.git', + 'http://[fd00::5]:3000/org/repo.git', + 'git://[2a01:4f8::1]/org/repo.git', + 'git://[2a01:4f8::1]:9418/org/repo.git', + 'git@[2a01:4f8::1]:org/repo.git', + 'git@[fd00::5]:2222/org/repo.git', + ]; + + foreach ($validUrls as $url) { + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("IPv6 URL should be allowed: {$url}"); + } +}); + +it('rejects loopback and reserved IPv6 addresses', function () { + $rule = new ValidGitRepositoryUrl; + + $invalidUrls = [ + 'https://[::1]/org/repo', + 'http://[fe80::1]/org/repo', + 'https://[::ffff:127.0.0.1]/org/repo', + ]; + + foreach ($invalidUrls as $url) { + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("Reserved IPv6 URL should be rejected: {$url}"); + expect($validator->errors()->first('url'))->toBe('The url cannot point to internal hosts.'); + } +}); + +it('rejects brackets that are not an IPv6 host', function () { + $rule = new ValidGitRepositoryUrl; + + $invalidUrls = [ + 'https://[not-an-ip]/org/repo', + 'https://github.com/[2a01:4f8::1]/repo', + 'git@[::1;id]:org/repo.git', + 'https://[2a01:4f8::1]/org/[repo]', + ]; + + foreach ($invalidUrls as $url) { + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->fails())->toBeTrue("URL should be rejected: {$url}"); + expect($validator->errors()->first('url'))->toContain('invalid characters'); + } +}); + it('rejects dangerous shell metacharacters', function () { $rule = new ValidGitRepositoryUrl;