diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index c5559576d..6f7e915c9 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -7,6 +7,7 @@ use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; +use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; @@ -104,7 +105,7 @@ protected function rules(): array if ($this->current_step === 2) { $rules = array_merge($rules, [ - 'server_name' => 'required|string|max:255', + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], 'selected_location' => 'required|string', 'selected_image' => 'required|integer', 'selected_server_type' => 'required|string', @@ -361,9 +362,12 @@ private function createHetznerServer(string $token): array ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); } + // Normalize server name to lowercase for RFC 1123 compliance + $normalizedServerName = strtolower(trim($this->server_name)); + // Prepare server creation parameters $params = [ - 'name' => $this->server_name, + 'name' => $normalizedServerName, 'server_type' => $this->selected_server_type, 'image' => $this->selected_image, 'location' => $this->selected_location, diff --git a/app/Rules/ValidHostname.php b/app/Rules/ValidHostname.php new file mode 100644 index 000000000..b6b2b8d32 --- /dev/null +++ b/app/Rules/ValidHostname.php @@ -0,0 +1,114 @@ + 253) { + $fail('The :attribute must not exceed 253 characters.'); + + return; + } + + // Check for dangerous shell metacharacters + $dangerousChars = [ + ';', '|', '&', '$', '`', '(', ')', '{', '}', + '<', '>', '\n', '\r', '\0', '"', "'", '\\', + '!', '*', '?', '[', ']', '~', '^', ':', '#', + '@', '%', '=', '+', ',', ' ', + ]; + + foreach ($dangerousChars as $char) { + if (str_contains($hostname, $char)) { + try { + $logData = [ + 'hostname' => $hostname, + 'character' => $char, + ]; + + if (function_exists('request') && app()->has('request')) { + $logData['ip'] = request()->ip(); + } + + if (function_exists('auth') && app()->has('auth')) { + $logData['user_id'] = auth()->id(); + } + + Log::warning('Hostname validation failed - dangerous character', $logData); + } catch (\Throwable $e) { + // 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.'); + + return; + } + } + + // 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.'); + + return; + } + + // Check for consecutive dots + if (str_contains($hostname, '..')) { + $fail('The :attribute cannot contain consecutive dots.'); + + return; + } + + // Split into labels (segments between dots) + $labels = explode('.', $hostname); + + foreach ($labels as $label) { + // Check label length (RFC 1123: max 63 characters per label) + if (strlen($label) < 1 || strlen($label) > 63) { + $fail('The :attribute contains an invalid label. Each segment must be 1-63 characters.'); + + return; + } + + // Check if label starts or ends with hyphen + if (str_starts_with($label, '-') || str_ends_with($label, '-')) { + $fail('The :attribute contains an invalid label. Labels cannot start or end with a hyphen.'); + + return; + } + + // Check if label contains only valid characters (lowercase 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.'); + + return; + } + + // RFC 1123 allows labels to be all numeric (unlike RFC 952) + // So we don't need to check for all-numeric labels + } + } +} diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index 394b55a4b..0f178bd34 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -2,20 +2,25 @@
@can('viewAny', App\Models\CloudProviderToken::class)
-
- - -
- + + +
+
+ - Hetzner +
+
Connect a Hetzner Server
+
+ Deploy servers directly from your Hetzner Cloud account +
+
- - - -
+
+ + +
diff --git a/tests/Unit/ValidHostnameTest.php b/tests/Unit/ValidHostnameTest.php new file mode 100644 index 000000000..859262c3e --- /dev/null +++ b/tests/Unit/ValidHostnameTest.php @@ -0,0 +1,74 @@ +validate('server_name', $hostname, function () use (&$failCalled) { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +})->with([ + 'simple hostname' => 'myserver', + 'hostname with hyphen' => 'my-server', + 'hostname with numbers' => 'server123', + 'hostname starting with number' => '123server', + 'all numeric hostname' => '12345', + 'fqdn' => 'server.example.com', + '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), +]); + +it('rejects invalid RFC 1123 hostnames', function (string $hostname, string $expectedError) { + $rule = new ValidHostname; + $failCalled = false; + $errorMessage = ''; + + $rule->validate('server_name', $hostname, function ($message) use (&$failCalled, &$errorMessage) { + $failCalled = true; + $errorMessage = $message; + }); + + 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 (.)'], + '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'], + 'ends with dot' => ['myserver.', 'cannot start or end with a dot'], + 'consecutive dots' => ['my..server', 'consecutive dots'], + '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 (.)'], +]); + +it('accepts empty hostname', function () { + $rule = new ValidHostname; + $failCalled = false; + + $rule->validate('server_name', '', function () use (&$failCalled) { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +}); + +it('trims whitespace before validation', function () { + $rule = new ValidHostname; + $failCalled = false; + + $rule->validate('server_name', ' myserver ', function () use (&$failCalled) { + $failCalled = true; + }); + + expect($failCalled)->toBeFalse(); +});