Files
coolify/tests/Unit/ValidGitRepositoryUrlTest.php
T

560 lines
19 KiB
PHP

<?php
use App\Rules\ValidGitRepositoryUrl;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
it('validates standard GitHub URLs', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://github.com/user/repo',
'https://github.com/user/repo.git',
'https://github.com/user/repo-with-dashes',
'https://github.com/user/repo_with_underscores',
'https://github.com/user/repo.with.dots',
'https://github.com/organization/repository',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for URL: {$url}");
}
});
it('validates GitLab URLs', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://gitlab.com/user/repo',
'https://gitlab.com/user/repo.git',
'https://gitlab.com/organization/repository',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for URL: {$url}");
}
});
it('validates Bitbucket URLs', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://bitbucket.org/user/repo',
'https://bitbucket.org/user/repo.git',
'https://bitbucket.org/organization/repository',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for URL: {$url}");
}
});
it('validates tangled.sh URLs with @ symbol', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://tangled.org/@tangled.org/site',
'https://tangled.org/@user/repo',
'https://tangled.org/@organization/project',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for URL: {$url}");
}
});
it('validates SourceHut URLs with ~ symbol', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://git.sr.ht/~user/repo',
'https://git.sr.ht/~user/project',
'https://git.sr.ht/~organization/repository',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for URL: {$url}");
}
});
it('validates other Git hosting services', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://codeberg.org/user/repo',
'https://codeberg.org/user/repo.git',
'https://gitea.com/user/repo',
'https://gitea.com/user/repo.git',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for URL: {$url}");
}
});
it('validates SSH URLs when allowed', function () {
$rule = new ValidGitRepositoryUrl(allowSSH: true);
$validUrls = [
'git@github.com:user/repo.git',
'git@gitlab.com:user/repo.git',
'git@bitbucket.org:user/repo.git',
'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) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for SSH URL: {$url}");
}
});
it('rejects non-SSH email-like repository URLs', function () {
$rule = new ValidGitRepositoryUrl;
$validator = Validator::make(['url' => 'custom-user@git.example.com'], ['url' => $rule]);
expect($validator->fails())->toBeTrue();
});
it('rejects SSH URLs when not allowed', function () {
$rule = new ValidGitRepositoryUrl(allowSSH: false);
$invalidUrls = [
'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) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("SSH URL should be rejected: {$url}");
expect($validator->errors()->first('url'))->toContain('SSH URLs are not allowed');
}
});
it('validates git:// protocol URLs', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'git://github.com/user/repo.git',
'git://gitlab.com/user/repo.git',
'git://git.sr.ht:~user/repo.git',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Failed for git:// URL: {$url}");
}
});
it('rejects URLs with query parameters', function () {
$rule = new ValidGitRepositoryUrl;
$invalidUrls = [
'https://github.com/user/repo?ref=main',
'https://github.com/user/repo?token=abc123',
'https://github.com/user/repo?utm_source=test',
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("URL with query parameters should be rejected: {$url}");
expect($validator->errors()->first('url'))->toContain('invalid characters');
}
});
it('rejects URLs with fragments', function () {
$rule = new ValidGitRepositoryUrl;
$invalidUrls = [
'https://github.com/user/repo#main',
'https://github.com/user/repo#readme',
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("URL with fragments should be rejected: {$url}");
expect($validator->errors()->first('url'))->toContain('invalid characters');
}
});
it('rejects internal/localhost URLs', function () {
$rule = new ValidGitRepositoryUrl;
$invalidUrls = [
'https://localhost/user/repo',
'https://127.0.0.1/user/repo',
'https://0.0.0.0/user/repo',
'https://::1/user/repo',
'https://example.local/user/repo',
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Internal URL should be rejected: {$url}");
$errorMessage = $validator->errors()->first('url');
expect(in_array($errorMessage, [
'The url cannot point to internal hosts.',
'The url cannot use IP addresses.',
'The url is not a valid URL.',
]))->toBeTrue("Unexpected error message: {$errorMessage}");
}
});
it('rejects IP addresses when not allowed', function () {
$rule = new ValidGitRepositoryUrl(allowIP: false);
$invalidUrls = [
'https://192.168.1.1/user/repo',
'https://10.0.0.1/user/repo',
'https://172.16.0.1/user/repo',
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("IP address URL should be rejected: {$url}");
expect($validator->errors()->first('url'))->toContain('IP addresses');
}
});
it('allows IP addresses when explicitly allowed', function () {
$rule = new ValidGitRepositoryUrl(allowIP: true);
$validUrls = [
'https://192.168.1.1/user/repo',
'https://10.0.0.1/user/repo',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("IP address URL should be allowed: {$url}");
}
});
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;
$dangerousChars = [';', '|', '&', '$', '`', '(', ')', '{', '}', '[', ']', '<', '>', '\n', '\r', '\0', '"', "'", '\\', '!', '?', '*', '^', '%', '=', '+', '#'];
foreach ($dangerousChars as $char) {
$url = "https://github.com/user/repo{$char}";
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("URL with dangerous character '{$char}' should be rejected");
expect($validator->errors()->first('url'))->toContain('invalid characters');
}
});
it('rejects command substitution patterns', function () {
$rule = new ValidGitRepositoryUrl;
$dangerousPatterns = [
'https://github.com/user/$(whoami)',
'https://github.com/user/${USER}',
'https://github.com/user;;',
'https://github.com/user&&',
'https://github.com/user||',
'https://github.com/user>>',
'https://github.com/user<<',
'https://github.com/user\\n',
'https://github.com/user../',
];
foreach ($dangerousPatterns as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("URL with dangerous pattern should be rejected: {$url}");
$errorMessage = $validator->errors()->first('url');
expect(in_array($errorMessage, [
'The url contains invalid characters.',
'The url contains invalid patterns.',
]))->toBeTrue("Unexpected error message: {$errorMessage}");
}
});
it('rejects invalid URL formats', function () {
$rule = new ValidGitRepositoryUrl;
$invalidUrls = [
'not-a-url',
'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@',
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Invalid URL format should be rejected: {$url}");
}
});
it('accepts empty values', function () {
$rule = new ValidGitRepositoryUrl;
$validator = Validator::make(['url' => ''], ['url' => $rule]);
expect($validator->passes())->toBeTrue('Empty URL should be accepted');
$validator = Validator::make(['url' => null], ['url' => $rule]);
expect($validator->passes())->toBeTrue('Null URL should be accepted');
});
it('validates complex repository paths', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://github.com/user/repo-with-many-dashes',
'https://github.com/user/repo_with_many_underscores',
'https://github.com/user/repo.with.many.dots',
'https://github.com/user/repo@version',
'https://github.com/user/repo~backup',
'https://github.com/user/repo@version~backup',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Complex path should be valid: {$url}");
}
});
it('validates nested repository paths', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://github.com/org/suborg/repo',
'https://gitlab.com/group/subgroup/project',
'https://tangled.org/@org/suborg/project',
'https://git.sr.ht/~user/project/subproject',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Nested path should be valid: {$url}");
}
});
it('provides meaningful error messages', function () {
$rule = new ValidGitRepositoryUrl;
$testCases = [
[
'url' => 'https://github.com/user; rm -rf /',
'expectedError' => 'invalid characters',
],
[
'url' => 'https://github.com/user/repo?token=secret',
'expectedError' => 'invalid characters',
],
[
'url' => 'https://localhost/user/repo',
'expectedError' => 'internal hosts',
],
];
foreach ($testCases as $testCase) {
$validator = Validator::make(['url' => $testCase['url']], ['url' => $rule]);
expect($validator->fails())->toBeTrue("Should fail for: {$testCase['url']}");
expect($validator->errors()->first('url'))->toContain($testCase['expectedError']);
}
});
it('allows percent-encoded repository paths', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://dev.azure.com/org/My%20Project/_git/repo',
'https://org@dev.azure.com/org/My%20Project/_git/My%20Repo',
'https://org.visualstudio.com/My%20Project/_git/repo',
'git@ssh.dev.azure.com:v3/org/My%20Project/repo',
'ssh://git@ssh.dev.azure.com/v3/org/My%20Project/repo',
'https://git.example.com/group/caf%C3%A9.git',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Percent-encoded URL should be allowed: {$url}");
}
});
it('validates repository URL formats of common git hosts', function () {
$rule = new ValidGitRepositoryUrl;
$validUrls = [
'https://dev.azure.com/org/project/_git/repo',
'https://org@dev.azure.com/org/project/_git/repo',
'git@ssh.dev.azure.com:v3/org/project/repo',
'https://org.visualstudio.com/DefaultCollection/project/_git/repo',
'org@vs-ssh.visualstudio.com:v3/org/project/repo',
'https://git-codecommit.us-east-2.amazonaws.com/v1/repos/MyDemoRepo',
'ssh://APKAEIBAERJR2EXAMPLE@git-codecommit.us-east-2.amazonaws.com/v1/repos/MyDemoRepo',
'https://bitbucket.example.com/scm/PROJ/repo.git',
'ssh://git@bitbucket.example.com:7999/PROJ/repo.git',
'https://gerrit.example.com/a/project',
'ssh://user@gerrit.example.com:29418/project',
'git@git.sr.ht:~user/repo',
'https://gitlab.com/group/subgroup/subsubgroup/project.git',
'https://gitea.example.com:3000/user/repo.git',
'ssh://git@codeberg.org/user/repo.git',
];
foreach ($validUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->passes())->toBeTrue("Git host URL should be allowed: {$url}");
}
});
it('rejects malformed or dangerous percent-encoding', function () {
$rule = new ValidGitRepositoryUrl;
$invalidUrls = [
'https://github.com/user/repo%',
'https://github.com/user/repo%2',
'https://github.com/user/repo%zz',
'https://github.com/user/repo%0A',
'https://github.com/user/repo%0d%0aprotocol=https',
'https://github.com/user/repo%00',
'https://github.com/user/repo%09',
'https://github.com/user/repo%7F',
'https://github.com/user/repo%3Bid',
'https://github.com/user/%24%28whoami%29',
'https://github.com/user/%60id%60',
'https://github.com/user/%2e%2e%2fetc',
'https://github.com/user/..%2Fetc',
'https://github.com/user/repo%2527',
'https://github.com/user/repo%27',
'https://github.com/user/repo%3Fref=main',
'https://git%68ub.com/user/repo',
'https://us%40er@github.com/user/repo',
'git@github.com:user/repo%0A',
'git@github.com:user/%3Bid',
'ssh://git@github.com/user/repo%0A',
'ssh://git@github.com/user/%2e%2e%2frepo',
'git://github.com/user/repo%0A',
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue("URL should be rejected: {$url}");
}
});
it('rejects raw control characters', function () {
$rule = new ValidGitRepositoryUrl;
$invalidUrls = [
"ssh://git@github.com/user/repo.git\n",
"git@github.com:user/repo.git\n",
"git://github.com/user/repo.git\n",
"https://github.com/user/repo\t",
];
foreach ($invalidUrls as $url) {
$validator = Validator::make(['url' => $url], ['url' => $rule]);
expect($validator->fails())->toBeTrue('URL with a control character should be rejected: '.json_encode($url));
expect($validator->errors()->first('url'))->toContain('invalid characters');
}
});