diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index d549961dc..21509d65a 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -136,7 +136,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Validate path contains only safe characters $path = $parsed['path'] ?? ''; - if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.]+$/', $path)) { + if (! empty($path) && ! preg_match('/^[a-zA-Z0-9\-_\/\.@~]+$/', $path)) { $fail('The :attribute path contains invalid characters.'); return; diff --git a/tests/Unit/ValidGitRepositoryUrlTest.php b/tests/Unit/ValidGitRepositoryUrlTest.php new file mode 100644 index 000000000..12eb419f2 --- /dev/null +++ b/tests/Unit/ValidGitRepositoryUrlTest.php @@ -0,0 +1,356 @@ + $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', + ]; + + foreach ($validUrls as $url) { + $validator = Validator::make(['url' => $url], ['url' => $rule]); + expect($validator->passes())->toBeTrue("Failed for SSH URL: {$url}"); + } +}); + +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', + ]; + + 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', + ]; + + 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('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', + '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']); + } +});