feat: implement ValidHostname validation rule and integrate it into server creation process
This commit is contained in:
parent
bd88bbca5b
commit
2e21d875af
4 changed files with 209 additions and 12 deletions
|
|
@ -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
114
app/Rules/ValidHostname.php
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
74
tests/Unit/ValidHostnameTest.php
Normal file
74
tests/Unit/ValidHostnameTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue