feat: implement ValidHostname validation rule and integrate it into server creation process

This commit is contained in:
Andras Bacsai 2025-10-10 11:03:13 +02:00
parent bd88bbca5b
commit 2e21d875af
4 changed files with 209 additions and 12 deletions

View file

@ -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,

114
app/Rules/ValidHostname.php Normal file
View file

@ -0,0 +1,114 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class ValidHostname implements ValidationRule
{
/**
* Run the validation rule.
*
* Validates hostname according to RFC 1123:
* - Must be 1-253 characters total
* - Each label (segment between dots) must be 1-63 characters
* - Labels can contain lowercase letters (a-z), digits (0-9), and hyphens (-)
* - Labels cannot start or end with a hyphen
* - Labels cannot be all numeric
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$hostname = trim($value);
// Check total length (RFC 1123: max 253 characters)
if (strlen($hostname) > 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
}
}
}

View file

@ -2,20 +2,25 @@
<div class="flex flex-col gap-4">
@can('viewAny', App\Models\CloudProviderToken::class)
<div>
<div class="flex gap-2 flex-wrap">
<x-modal-input title="Connect a Hetzner Server">
<x-slot:button-title>
<div class="flex items-center gap-2">
<svg class="w-5 h-5" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<x-modal-input title="Connect a Hetzner Server">
<x-slot:content>
<div class="relative gap-2 cursor-pointer box group">
<div class="flex items-center gap-4 mx-6">
<svg class="w-10 h-10 flex-shrink-0" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#D50C2D" rx="8" />
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
</svg>
<span>Hetzner</span>
<div class="flex flex-col justify-center flex-1">
<div class="box-title">Connect a Hetzner Server</div>
<div class="box-description">
Deploy servers directly from your Hetzner Cloud account
</div>
</div>
</div>
</x-slot:button-title>
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>

View file

@ -0,0 +1,74 @@
<?php
use App\Rules\ValidHostname;
it('accepts valid RFC 1123 hostnames', function (string $hostname) {
$rule = new ValidHostname;
$failCalled = false;
$rule->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();
});