fix(server): share SSH username validation

Centralize SSH username rules and sanitization so dotted usernames are
accepted consistently across API, onboarding, and Livewire server forms.
This commit is contained in:
Andras Bacsai
2026-06-03 11:38:48 +02:00
parent 7c97b8bfb3
commit bc2afdf02e
8 changed files with 221 additions and 8 deletions
@@ -13,6 +13,7 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -487,7 +488,7 @@ class ServersController extends Controller
'ip' => ['string', 'required', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9._-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
@@ -666,7 +667,7 @@ class ServersController extends Controller
'ip' => ['string', 'nullable', new ValidServerIp],
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable',
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9._-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(required: false),
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
+3 -2
View File
@@ -8,6 +8,7 @@ use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Livewire\Attributes\Url;
use Livewire\Component;
@@ -278,7 +279,7 @@ class Index extends Component
'remoteServerName' => 'required|string',
'remoteServerHost' => 'required|string',
'remoteServerPort' => 'required|integer',
'remoteServerUser' => 'required|string|regex:/^[a-zA-Z0-9._-]+$/',
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
]);
$this->privateKey = formatPrivateKey($this->privateKey);
@@ -467,7 +468,7 @@ class Index extends Component
{
$this->validate([
'remoteServerPort' => 'required|integer|min:1|max:65535',
'remoteServerUser' => 'required|string|regex:/^[a-zA-Z0-9._-]+$/',
'remoteServerUser' => ValidationPatterns::serverUsernameRules(),
]);
$this->createdServer->update([
+2 -1
View File
@@ -57,7 +57,7 @@ class ByIp extends Component
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', 'string', new ValidServerIp],
'user' => ['required', 'string', 'regex:/^[a-zA-Z0-9._-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
'is_build_server' => 'required|boolean',
];
@@ -75,6 +75,7 @@ class ByIp extends Component
'ip.string' => 'The IP Address/Domain must be a string.',
'user.required' => 'The User field is required.',
'user.string' => 'The User field must be a string.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.',
'port.integer' => 'The Port field must be an integer.',
'port.between' => 'The Port field must be between 1 and 65535.',
+2 -1
View File
@@ -110,7 +110,7 @@ class Show extends Component
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9._-]+$/'],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
@@ -140,6 +140,7 @@ class Show extends Component
[
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
...ValidationPatterns::serverUsernameMessages(),
'port.required' => 'The Port field is required.',
'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+3 -2
View File
@@ -17,6 +17,7 @@ use App\Livewire\Server\Proxy;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\ConfigurationRepository;
use App\Support\ValidationPatterns;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
@@ -945,10 +946,10 @@ $schema://$host {
{
return Attribute::make(
get: function ($value) {
return preg_replace('/[^A-Za-z0-9.\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
},
set: function ($value) {
return preg_replace('/[^A-Za-z0-9.\-_]/', '', $value);
return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value);
}
);
}
+33
View File
@@ -35,6 +35,17 @@ class ValidationPatterns
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for SSH usernames.
* Allows alphanumeric characters, dots, hyphens, and underscores.
*/
public const SERVER_USERNAME_PATTERN = '/^[a-zA-Z0-9._-]+$/';
/**
* Pattern for removing characters not allowed in SSH usernames.
*/
public const INVALID_SERVER_USERNAME_CHARACTERS_PATTERN = '/[^A-Za-z0-9.\-_]/';
/**
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
*
@@ -124,6 +135,28 @@ class ValidationPatterns
return $rules;
}
/**
* Get validation rules for SSH username fields.
*/
public static function serverUsernameRules(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'string',
'regex:'.self::SERVER_USERNAME_PATTERN,
];
}
/**
* Get validation messages for SSH username fields.
*/
public static function serverUsernameMessages(string $field = 'user', string $label = 'User'): array
{
return [
"{$field}.regex" => "The {$label} may only contain letters, numbers, dots, hyphens, and underscores.",
];
}
/**
* Get validation messages for database identifier fields.
*/
@@ -0,0 +1,156 @@
<?php
use App\Livewire\Boarding\Index as BoardingIndex;
use App\Livewire\Server\New\ByIp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.driver' => 'file']);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
'uuid' => (string) new Cuid2,
'name' => 'Test SSH Key',
'description' => 'Test SSH Key',
'private_key' => 'test-private-key',
'team_id' => $this->team->id,
]));
$token = $this->user->createToken('write-token', ['write']);
$token->accessToken->forceFill(['team_id' => $this->team->id])->save();
$this->token = $token->plainTextToken;
});
it('creates a server through the API with a dotted SSH username', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Dotted User Server',
'ip' => '192.0.2.10',
'private_key_uuid' => $this->privateKey->uuid,
'user' => 'deploy.user',
]);
$response->assertCreated();
$this->assertDatabaseHas('servers', [
'ip' => '192.0.2.10',
'user' => 'deploy.user',
]);
});
it('updates a server through the API with a dotted SSH username', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'user' => 'deploy',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, [
'user' => 'deploy.user',
]);
$response->assertStatus(201);
expect($server->fresh()->user)->toBe('deploy.user');
});
it('rejects unsafe SSH usernames through the API', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/servers/'.$server->uuid, [
'user' => 'deploy$user',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors' => ['user']]);
});
it('allows dotted SSH usernames in the server creation form', function () {
$this->actingAs($this->user);
Livewire::test(ByIp::class, [
'private_keys' => collect([$this->privateKey]),
'limit_reached' => false,
])
->set('name', 'Dotted User Server')
->set('ip', '192.0.2.20')
->set('user', 'deploy.user')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasNoErrors(['user']);
$this->assertDatabaseHas('servers', [
'ip' => '192.0.2.20',
'user' => 'deploy.user',
]);
});
it('rejects unsafe SSH usernames in the server creation form', function () {
$this->actingAs($this->user);
Livewire::test(ByIp::class, [
'private_keys' => collect([$this->privateKey]),
'limit_reached' => false,
])
->set('name', 'Unsafe User Server')
->set('ip', '192.0.2.21')
->set('user', 'deploy$user')
->set('private_key_id', $this->privateKey->id)
->call('submit')
->assertHasErrors(['user' => ['regex']]);
});
it('rejects unsafe SSH usernames during onboarding server creation', function () {
$this->actingAs($this->user);
Livewire::test(BoardingIndex::class)
->set('createdPrivateKey', $this->privateKey)
->set('remoteServerName', 'Unsafe User Server')
->set('remoteServerHost', '192.0.2.30')
->set('remoteServerPort', 22)
->set('remoteServerUser', 'deploy$user')
->call('saveServer')
->assertHasErrors(['remoteServerUser' => ['regex']]);
});
it('rejects unsafe SSH usernames during onboarding server validation', function () {
$this->actingAs($this->user);
$server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'user' => 'deploy',
]);
Livewire::test(BoardingIndex::class)
->set('createdServer', $server)
->set('remoteServerPort', 22)
->set('remoteServerUser', 'deploy$user')
->call('saveAndValidateServer')
->assertHasErrors(['remoteServerUser' => ['regex']]);
});
+19
View File
@@ -0,0 +1,19 @@
<?php
use App\Models\Server;
use App\Support\ValidationPatterns;
it('provides shared validation rules for SSH usernames', function () {
expect(ValidationPatterns::SERVER_USERNAME_PATTERN)->toBe('/^[a-zA-Z0-9._-]+$/');
expect(ValidationPatterns::serverUsernameRules())->toContain('regex:'.ValidationPatterns::SERVER_USERNAME_PATTERN);
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy.user'))->toBe(1);
expect(preg_match(ValidationPatterns::SERVER_USERNAME_PATTERN, 'deploy$user'))->toBe(0);
});
it('preserves dots when sanitizing server SSH usernames', function () {
$server = new Server;
$server->user = 'deploy.user';
expect($server->user)->toBe('deploy.user');
});