From 988dd57cf4f30fcaee3df22f9500d13372ff791e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:03:08 +0100 Subject: [PATCH] feat(validation): make hostname validation case-insensitive and expand allowed characters - Normalize hostnames to lowercase for RFC 1123 compliance while accepting uppercase input - Expand NAME_PATTERN to allow parentheses, hash, comma, colon, and plus characters - Add fallback to random name generation when application name doesn't meet minimum requirements - Add comprehensive test coverage for validation patterns and edge cases --- app/Rules/ValidHostname.php | 9 ++- app/Support/ValidationPatterns.php | 4 +- bootstrap/helpers/shared.php | 11 +++- tests/Unit/ValidHostnameTest.php | 11 ++-- tests/Unit/ValidationPatternsTest.php | 82 +++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/ValidationPatternsTest.php diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php index b6b2b8d32..89b68663b 100644 --- a/app/Rules/ValidHostname.php +++ b/app/Rules/ValidHostname.php @@ -62,12 +62,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void // Ignore errors when facades are not available (e.g., in unit tests) } - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } } + // Normalize to lowercase for validation (RFC 1123 hostnames are case-insensitive) + $hostname = strtolower($hostname); + // Additional validation: hostname should not start or end with a dot if (str_starts_with($hostname, '.') || str_ends_with($hostname, '.')) { $fail('The :attribute cannot start or end with a dot.'); @@ -100,9 +103,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - // Check if label contains only valid characters (lowercase letters, digits, hyphens) + // Check if label contains only valid characters (letters, digits, hyphens) if (! preg_match('/^[a-z0-9-]+$/', $label)) { - $fail('The :attribute contains invalid characters. Only lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); + $fail('The :attribute contains invalid characters. Only letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.) are allowed.'); return; } diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index fdf2b12a6..7b8251729 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -10,7 +10,7 @@ class ValidationPatterns /** * Pattern for names excluding all dangerous characters */ - public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&]+$/u'; + public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u'; /** * Pattern for descriptions excluding all dangerous characters with some additional allowed characters @@ -96,7 +96,7 @@ public static function descriptionRules(bool $required = false, int $maxLength = public static function nameMessages(): array { return [ - 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ &', + 'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +', 'name.min' => 'The name must be at least :min characters.', 'name.max' => 'The name may not be greater than :max characters.', ]; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index ce9ab5283..a8cffcaff 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -341,7 +341,16 @@ function generate_application_name(string $git_repository, string $git_branch, ? $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; - return Str::kebab("$repo_name:$git_branch-$cuid"); + $name = Str::kebab("$repo_name:$git_branch-$cuid"); + + // Strip characters not allowed by NAME_PATTERN + $name = preg_replace('/[^\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+/u', '', $name); + + if (empty($name) || mb_strlen($name) < 3) { + return generate_random_name($cuid); + } + + return $name; } /** diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php index 859262c3e..6580a7c5d 100644 --- a/tests/Unit/ValidHostnameTest.php +++ b/tests/Unit/ValidHostnameTest.php @@ -21,6 +21,8 @@ 'subdomain' => 'web.app.example.com', 'max label length' => str_repeat('a', 63), 'max total length' => str_repeat('a', 63).'.'.str_repeat('b', 63).'.'.str_repeat('c', 63).'.'.str_repeat('d', 59), + 'uppercase hostname' => 'MyServer', + 'mixed case fqdn' => 'MyServer.Example.COM', ]); it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) { @@ -36,8 +38,7 @@ expect($failCalled)->toBeTrue(); expect($errorMessage)->toContain($expectedError); })->with([ - 'uppercase letters' => ['MyServer', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'underscore' => ['my_server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'underscore' => ['my_server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], 'starts with hyphen' => ['-myserver', 'cannot start or end with a hyphen'], 'ends with hyphen' => ['myserver-', 'cannot start or end with a hyphen'], 'starts with dot' => ['.myserver', 'cannot start or end with a dot'], @@ -46,9 +47,9 @@ 'too long total' => [str_repeat('a', 254), 'must not exceed 253 characters'], 'label too long' => [str_repeat('a', 64), 'must be 1-63 characters'], 'empty label' => ['my..server', 'consecutive dots'], - 'special characters' => ['my@server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'space' => ['my server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], - 'shell metacharacters' => ['my;server', 'lowercase letters (a-z), numbers (0-9), hyphens (-), and dots (.)'], + 'special characters' => ['my@server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'space' => ['my server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], + 'shell metacharacters' => ['my;server', 'letters (a-z, A-Z), numbers (0-9), hyphens (-), and dots (.)'], ]); it('accepts empty hostname', function () { diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php new file mode 100644 index 000000000..0da8f9a4d --- /dev/null +++ b/tests/Unit/ValidationPatternsTest.php @@ -0,0 +1,82 @@ +toBe(1); +})->with([ + 'simple name' => 'My Server', + 'name with hyphen' => 'my-server', + 'name with underscore' => 'my_server', + 'name with dot' => 'my.server', + 'name with slash' => 'my/server', + 'name with at sign' => 'user@host', + 'name with ampersand' => 'Tom & Jerry', + 'name with parentheses' => 'My Server (Production)', + 'name with hash' => 'Server #1', + 'name with comma' => 'Server, v2', + 'name with colon' => 'Server: Production', + 'name with plus' => 'C++ App', + 'unicode name' => 'Ünïcödé Sërvér', + 'unicode chinese' => '我的服务器', + 'numeric name' => '12345', + 'complex name' => 'App #3 (staging): v2.1+hotfix', +]); + +it('rejects names with dangerous characters', function (string $name) { + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(0); +})->with([ + 'semicolon' => 'my;server', + 'pipe' => 'my|server', + 'dollar sign' => 'my$server', + 'backtick' => 'my`server', + 'backslash' => 'my\\server', + 'less than' => 'my 'my>server', + 'curly braces' => 'my{server}', + 'square brackets' => 'my[server]', + 'tilde' => 'my~server', + 'caret' => 'my^server', + 'question mark' => 'my?server', + 'percent' => 'my%server', + 'double quote' => 'my"server', + 'exclamation' => 'my!server', + 'asterisk' => 'my*server', +]); + +it('generates nameRules with correct defaults', function () { + $rules = ValidationPatterns::nameRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('min:3') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::NAME_PATTERN); +}); + +it('generates nullable nameRules when not required', function () { + $rules = ValidationPatterns::nameRules(required: false); + + expect($rules)->toContain('nullable') + ->not->toContain('required'); +}); + +it('generates application names that comply with NAME_PATTERN', function (string $repo, string $branch) { + $name = generate_application_name($repo, $branch, 'testcuid'); + + expect(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +})->with([ + 'normal repo' => ['owner/my-app', 'main'], + 'repo with dots' => ['repo.with.dots', 'feat/branch'], + 'repo with plus' => ['C++ App', 'main'], + 'branch with parens' => ['my-app', 'fix(auth)-login'], + 'repo with exclamation' => ['my-app!', 'main'], + 'repo with brackets' => ['app[test]', 'develop'], +]); + +it('falls back to random name when repo produces empty name', function () { + $name = generate_application_name('!!!', 'main', 'testcuid'); + + expect(mb_strlen($name))->toBeGreaterThanOrEqual(3) + ->and(preg_match(ValidationPatterns::NAME_PATTERN, $name))->toBe(1); +});