mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-17 17:21:04 +00:00
feat: implement ValidHostname validation rule and integrate it into server creation process
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Models\CloudProviderToken;
|
|||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
|
use App\Rules\ValidHostname;
|
||||||
use App\Services\HetznerService;
|
use App\Services\HetznerService;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -104,7 +105,7 @@ class ByHetzner extends Component
|
|||||||
|
|
||||||
if ($this->current_step === 2) {
|
if ($this->current_step === 2) {
|
||||||
$rules = array_merge($rules, [
|
$rules = array_merge($rules, [
|
||||||
'server_name' => 'required|string|max:255',
|
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
|
||||||
'selected_location' => 'required|string',
|
'selected_location' => 'required|string',
|
||||||
'selected_image' => 'required|integer',
|
'selected_image' => 'required|integer',
|
||||||
'selected_server_type' => 'required|string',
|
'selected_server_type' => 'required|string',
|
||||||
@@ -361,9 +362,12 @@ class ByHetzner extends Component
|
|||||||
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
|
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
|
// Prepare server creation parameters
|
||||||
$params = [
|
$params = [
|
||||||
'name' => $this->server_name,
|
'name' => $normalizedServerName,
|
||||||
'server_type' => $this->selected_server_type,
|
'server_type' => $this->selected_server_type,
|
||||||
'image' => $this->selected_image,
|
'image' => $this->selected_image,
|
||||||
'location' => $this->selected_location,
|
'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">
|
<div class="flex flex-col gap-4">
|
||||||
@can('viewAny', App\Models\CloudProviderToken::class)
|
@can('viewAny', App\Models\CloudProviderToken::class)
|
||||||
<div>
|
<div>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<x-modal-input title="Connect a Hetzner Server">
|
||||||
<x-modal-input title="Connect a Hetzner Server">
|
<x-slot:content>
|
||||||
<x-slot:button-title>
|
<div class="relative gap-2 cursor-pointer box group">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-4 mx-6">
|
||||||
<svg class="w-5 h-5" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
<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" />
|
<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" />
|
<path d="M40 40 H60 V90 H140 V40 H160 V160 H140 V110 H60 V160 H40 Z" fill="white" />
|
||||||
</svg>
|
</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>
|
</div>
|
||||||
</x-slot:button-title>
|
</div>
|
||||||
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
</x-slot:content>
|
||||||
</x-modal-input>
|
<livewire:server.new.by-hetzner :private_keys="$private_keys" :limit_reached="$limit_reached" />
|
||||||
</div>
|
</x-modal-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t dark:border-coolgray-300 my-4"></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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user