mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user