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.
This commit is contained in:
Andras Bacsai
2026-10-09 09:31:07 +02:00
parent 247015e30a
commit 8c4aa7bb00
5 changed files with 133 additions and 10 deletions
+19 -9
View File
@@ -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;
+1 -1
View File
@@ -4713,7 +4713,7 @@ function parseScpStyleGitUrl(?string $gitRepository): ?array
return null;
}
if (preg_match('/^(?<user>[A-Za-z0-9._-]+)@(?<host>[^:]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
if (preg_match('/^(?<user>[A-Za-z0-9._-]+)@(?<host>\[[0-9A-Fa-f:.]+\]|[^:\[\]]+):(?:(?<port>\d+)\/)?(?<path>.+)$/', $gitRepository, $matches) !== 1) {
return null;
}
@@ -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',
]);
});
+22
View File
@@ -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) {
+83
View File
@@ -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;