diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 9f97dd0d4..7ea6a871e 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -37,12 +37,13 @@ public function create(array $input): User if (User::count() == 0) { // If this is the first user, make them the root user // Team is already created in the database/seeders/ProductionSeeder.php - $user = User::create([ + $user = (new User)->forceFill([ 'id' => 0, 'name' => $input['name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); + $user->save(); $team = $user->teams()->first(); // Disable registration after first user is created diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index fd0282d96..03b36e4e0 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -14,14 +14,6 @@ private function removeSensitiveData($team) 'custom_server_limit', 'pivot', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $team->makeHidden([ - 'smtp_username', - 'smtp_password', - 'resend_api_key', - 'telegram_token', - ]); - } return serializeApiResponse($team); } diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 22605e1bb..d0121bdc5 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -16,7 +16,9 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Support\ValidationPatterns; use Illuminate\Support\Facades\Process; +use Livewire\Attributes\Locked; use Livewire\Component; class GetLogs extends Component @@ -29,12 +31,16 @@ class GetLogs extends Component public string $errors = ''; + #[Locked] public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|null $resource = null; + #[Locked] public ServiceApplication|ServiceDatabase|null $servicesubtype = null; + #[Locked] public Server $server; + #[Locked] public ?string $container = null; public ?string $displayName = null; @@ -54,7 +60,7 @@ class GetLogs extends Component public function mount() { if (! is_null($this->resource)) { - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $this->showTimeStamps = $this->resource->settings->is_include_timestamps; } else { if ($this->servicesubtype) { @@ -63,7 +69,7 @@ public function mount() $this->showTimeStamps = $this->resource->is_include_timestamps; } } - if ($this->resource?->getMorphClass() === \App\Models\Application::class) { + if ($this->resource?->getMorphClass() === Application::class) { if (str($this->container)->contains('-pr-')) { $this->pull_request = 'Pull Request: '.str($this->container)->afterLast('-pr-')->beforeLast('_')->value(); } @@ -74,11 +80,11 @@ public function mount() public function instantSave() { if (! is_null($this->resource)) { - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $this->resource->settings->is_include_timestamps = $this->showTimeStamps; $this->resource->settings->save(); } - if ($this->resource->getMorphClass() === \App\Models\Service::class) { + if ($this->resource->getMorphClass() === Service::class) { $serviceName = str($this->container)->beforeLast('-')->value(); $subType = $this->resource->applications()->where('name', $serviceName)->first(); if ($subType) { @@ -118,10 +124,20 @@ public function toggleStreamLogs() public function getLogs($refresh = false) { + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + $this->outputs = 'Unauthorized.'; + + return; + } if (! $this->server->isFunctional()) { return; } - if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { + if ($this->container && ! ValidationPatterns::isValidContainerName($this->container)) { + $this->outputs = 'Invalid container name.'; + + return; + } + if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { return; } if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { @@ -194,9 +210,15 @@ public function copyLogs(): string public function downloadAllLogs(): string { + if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { + return ''; + } if (! $this->server->isFunctional() || ! $this->container) { return ''; } + if (! ValidationPatterns::isValidContainerName($this->container)) { + return ''; + } if ($this->showTimeStamps) { if ($this->server->isSwarm()) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 4ed1252e4..3312f4c76 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -174,8 +174,11 @@ class Application extends BaseModel 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'docker_compose_location', + 'docker_compose_pr_location', 'docker_compose', + 'docker_compose_pr', 'docker_compose_raw', + 'docker_compose_pr_raw', 'docker_compose_domains', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', @@ -187,21 +190,19 @@ class Application extends BaseModel 'custom_nginx_configuration', 'custom_network_aliases', 'custom_healthcheck_found', + 'nixpkgsarchive', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', + 'connect_to_docker_network', + 'force_domain_override', + 'is_container_label_escape_enabled', + 'use_build_server', 'config_hash', 'last_online_at', 'restart_count', 'last_restart_at', 'last_restart_type', - 'environment_id', - 'destination_id', - 'destination_type', - 'source_id', - 'source_type', - 'private_key_id', - 'repository_project_id', ]; protected $appends = ['server_status']; @@ -1766,7 +1767,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $fileList = collect([".$workdir$composeFile"]); $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { - throw new RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}"); + throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); } $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); $gitVersion = str($getGitVersion)->explode(' ')->last(); @@ -1824,7 +1825,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } - throw new RuntimeException($e->getMessage()); + throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block $commands = collect([ diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 3afbc85ab..d34f2c86b 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -99,6 +99,7 @@ class ServerSetting extends Model ]; protected $casts = [ + 'force_disabled' => 'boolean', 'force_docker_cleanup' => 'boolean', 'docker_cleanup_threshold' => 'integer', 'sentinel_token' => 'encrypted', diff --git a/app/Models/Service.php b/app/Models/Service.php index 527328621..b3ff85e53 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -57,11 +57,6 @@ class Service extends BaseModel 'service_type', 'config_hash', 'compose_parsing_version', - 'environment_id', - 'server_id', - 'destination_id', - 'destination_type', - 'is_container_label_escape_enabled', ]; protected $appends = ['server_status', 'status']; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 05f5853e3..c192e5360 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -37,15 +37,12 @@ class StandaloneClickhouse extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'custom_docker_run_options', - 'clickhouse_db', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; protected $casts = [ - 'clickhouse_password' => 'encrypted', + 'clickhouse_admin_password' => 'encrypted', 'public_port_timeout' => 'integer', 'restart_count' => 'integer', 'last_restart_at' => 'datetime', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index af309f980..7cc74f0ce 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -36,9 +36,6 @@ class StandaloneDragonfly extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index ee07b4783..7a0d7f03d 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -37,9 +37,6 @@ class StandaloneKeydb extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index ad5220496..6cac9e5f4 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -39,10 +39,6 @@ class StandaloneMariadb extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'is_log_drain_enabled', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 590c173e1..5ca4ef5d3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -37,12 +37,6 @@ class StandaloneMongodb extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'ssl_mode', - 'is_log_drain_enabled', - 'is_include_timestamps', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index d991617b7..cf8d78a9c 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -38,12 +38,6 @@ class StandaloneMysql extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'ssl_mode', - 'is_log_drain_enabled', - 'is_include_timestamps', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 71034427f..7db334c5d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -40,12 +40,6 @@ class StandalonePostgresql extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'ssl_mode', - 'is_log_drain_enabled', - 'is_include_timestamps', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 4eb28e038..2320619cf 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -34,11 +34,6 @@ class StandaloneRedis extends BaseModel 'last_restart_at', 'last_restart_type', 'last_online_at', - 'public_port_timeout', - 'enable_ssl', - 'is_log_drain_enabled', - 'is_include_timestamps', - 'custom_docker_run_options', ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/Team.php b/app/Models/Team.php index 300280b99..8eb8fa050 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -43,6 +43,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen protected $fillable = [ 'name', 'description', + 'personal_team', 'show_boarding', 'custom_server_limit', ]; diff --git a/app/Models/User.php b/app/Models/User.php index 8ef5426a8..ad9a7af31 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,9 +49,6 @@ class User extends Authenticatable implements SendsEmail 'password', 'force_password_reset', 'marketing_emails', - 'pending_email', - 'email_change_code', - 'email_change_code_expires_at', ]; protected $hidden = [ @@ -98,7 +95,7 @@ protected static function boot() $team['id'] = 0; $team['name'] = 'Root Team'; } - $new_team = Team::create($team); + $new_team = Team::forceCreate($team); $user->teams()->attach($new_team, ['role' => 'owner']); }); @@ -201,7 +198,7 @@ public function recreate_personal_team() $team['id'] = 0; $team['name'] = 'Root Team'; } - $new_team = Team::create($team); + $new_team = Team::forceCreate($team); $this->teams()->attach($new_team, ['role' => 'owner']); return $new_team; @@ -412,11 +409,11 @@ public function requestEmailChange(string $newEmail): void $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); $expiresAt = Carbon::now()->addMinutes($expiryMinutes); - $this->update([ + $this->forceFill([ 'pending_email' => $newEmail, 'email_change_code' => $code, 'email_change_code_expires_at' => $expiresAt, - ]); + ])->save(); // Send verification email to new address $this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt)); diff --git a/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php new file mode 100644 index 000000000..a4a6988f2 --- /dev/null +++ b/database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php @@ -0,0 +1,39 @@ +chunkById(100, function ($clickhouses) { + foreach ($clickhouses as $clickhouse) { + $password = $clickhouse->clickhouse_admin_password; + + if (empty($password)) { + continue; + } + + // Skip if already encrypted (idempotent) + try { + Crypt::decryptString($password); + + continue; + } catch (Exception) { + // Not encrypted yet — encrypt it + } + + DB::table('standalone_clickhouses') + ->where('id', $clickhouse->id) + ->update(['clickhouse_admin_password' => Crypt::encryptString($password)]); + } + }); + } catch (Exception $e) { + echo 'Encrypting ClickHouse admin passwords failed.'; + echo $e->getMessage(); + } + } +} diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php index e3968a1c9..c4e93af63 100644 --- a/database/seeders/RootUserSeeder.php +++ b/database/seeders/RootUserSeeder.php @@ -45,12 +45,13 @@ public function run(): void } try { - User::create([ + $user = (new User)->forceFill([ 'id' => 0, 'name' => env('ROOT_USERNAME', 'Root User'), 'email' => env('ROOT_USER_EMAIL'), 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]); + $user->save(); echo "\n SUCCESS Root user created successfully.\n\n"; } catch (\Exception $e) { echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n"; diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php new file mode 100644 index 000000000..3e5a33b66 --- /dev/null +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -0,0 +1,162 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + // Server::created auto-creates a StandaloneDocker, reuse it + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('GetLogs locked properties', function () { + test('container property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'container'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('server property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'server'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('resource property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'resource'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); + + test('servicesubtype property has Locked attribute', function () { + $property = new ReflectionProperty(GetLogs::class, 'servicesubtype'); + $attributes = $property->getAttributes(Locked::class); + + expect($attributes)->not->toBeEmpty(); + }); +}); + +describe('GetLogs Livewire action validation', function () { + test('getLogs rejects invalid container name', function () { + // Make server functional by setting settings directly + $this->server->settings->forceFill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + // Reload server with fresh settings to ensure casted values + $server = Server::with('settings')->find($this->server->id); + + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'container;malicious-command', + ]) + ->call('getLogs') + ->assertSet('outputs', 'Invalid container name.'); + }); + + test('getLogs rejects unauthorized server access', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + Livewire::test(GetLogs::class, [ + 'server' => $otherServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('getLogs') + ->assertSet('outputs', 'Unauthorized.'); + }); + + test('downloadAllLogs returns empty for invalid container name', function () { + $this->server->settings->forceFill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->find($this->server->id); + + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'container$(whoami)', + ]) + ->call('downloadAllLogs') + ->assertReturned(''); + }); + + test('downloadAllLogs returns empty for unauthorized server', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + + Livewire::test(GetLogs::class, [ + 'server' => $otherServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('downloadAllLogs') + ->assertReturned(''); + }); +}); + +describe('GetLogs container name injection payloads are blocked by validation', function () { + test('newline injection payload is rejected', function () { + // The exact PoC payload from the advisory + $payload = "postgresql 2>/dev/null\necho '===RCE-START==='\nid\nwhoami\nhostname\ncat /etc/hostname\necho '===RCE-END==='\n#"; + expect(ValidationPatterns::isValidContainerName($payload))->toBeFalse(); + }); + + test('semicolon injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql;id'))->toBeFalse(); + }); + + test('backtick injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql`id`'))->toBeFalse(); + }); + + test('command substitution injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql$(whoami)'))->toBeFalse(); + }); + + test('pipe injection payload is rejected', function () { + expect(ValidationPatterns::isValidContainerName('postgresql|cat /etc/passwd'))->toBeFalse(); + }); + + test('valid container names are accepted', function () { + expect(ValidationPatterns::isValidContainerName('postgresql'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('my-app-container'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('service_db.v2'))->toBeTrue(); + expect(ValidationPatterns::isValidContainerName('coolify-proxy'))->toBeTrue(); + }); +}); diff --git a/tests/Feature/MassAssignmentProtectionTest.php b/tests/Feature/MassAssignmentProtectionTest.php index 7a5f97a4e..18de67ce7 100644 --- a/tests/Feature/MassAssignmentProtectionTest.php +++ b/tests/Feature/MassAssignmentProtectionTest.php @@ -48,19 +48,19 @@ } }); - test('Application model blocks mass assignment of identity fields', function () { + test('Application model blocks mass assignment of relationship IDs', function () { $application = new Application; + $dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id']; - expect($application->isFillable('id'))->toBeFalse('id should not be fillable'); - expect($application->isFillable('uuid'))->toBeFalse('uuid should not be fillable'); - expect($application->isFillable('created_at'))->toBeFalse('created_at should not be fillable'); - expect($application->isFillable('updated_at'))->toBeFalse('updated_at should not be fillable'); - expect($application->isFillable('deleted_at'))->toBeFalse('deleted_at should not be fillable'); + foreach ($dangerousFields as $field) { + expect($application->isFillable($field)) + ->toBeFalse("Application model should not allow mass assignment of '{$field}'"); + } }); test('Application model allows mass assignment of user-facing fields', function () { $application = new Application; - $userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'health_check_enabled', 'limits_memory', 'status']; + $userFields = ['name', 'description', 'git_repository', 'git_branch', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'health_check_path', 'limits_memory', 'status']; foreach ($userFields as $field) { expect($application->isFillable($field)) @@ -68,39 +68,21 @@ } }); - test('Application model allows mass assignment of relationship fields needed for create()', function () { - $application = new Application; - $relationFields = ['environment_id', 'destination_id', 'destination_type', 'source_id', 'source_type', 'private_key_id', 'repository_project_id']; - - foreach ($relationFields as $field) { - expect($application->isFillable($field)) - ->toBeTrue("Application model should allow mass assignment of '{$field}' for internal create() calls"); - } - }); - - test('Application fill ignores non-fillable fields', function () { - $application = new Application; - $application->fill([ - 'name' => 'test-app', - 'team_id' => 999, - ]); - - expect($application->name)->toBe('test-app'); - expect($application->team_id)->toBeNull(); - }); - test('Server model has $fillable and no conflicting $guarded', function () { $server = new Server; $fillable = $server->getFillable(); $guarded = $server->getGuarded(); expect($fillable)->not->toBeEmpty('Server model should have explicit $fillable'); + + // Guarded should be the default ['*'] when $fillable is set, not [] expect($guarded)->not->toBe([], 'Server model should not have $guarded = [] overriding $fillable'); }); test('Server model blocks mass assignment of dangerous fields', function () { $server = new Server; + // These fields should not be mass-assignable via the API expect($server->isFillable('id'))->toBeFalse(); expect($server->isFillable('uuid'))->toBeFalse(); expect($server->isFillable('created_at'))->toBeFalse(); @@ -114,6 +96,9 @@ expect($user->isFillable('remember_token'))->toBeFalse('remember_token should not be fillable'); expect($user->isFillable('two_factor_secret'))->toBeFalse('two_factor_secret should not be fillable'); expect($user->isFillable('two_factor_recovery_codes'))->toBeFalse('two_factor_recovery_codes should not be fillable'); + expect($user->isFillable('pending_email'))->toBeFalse('pending_email should not be fillable'); + expect($user->isFillable('email_change_code'))->toBeFalse('email_change_code should not be fillable'); + expect($user->isFillable('email_change_code_expires_at'))->toBeFalse('email_change_code_expires_at should not be fillable'); }); test('User model allows mass assignment of profile fields', function () { @@ -128,26 +113,21 @@ $team = new Team; expect($team->isFillable('id'))->toBeFalse(); - expect($team->isFillable('personal_team'))->toBeFalse('personal_team should not be fillable'); + expect($team->isFillable('use_instance_email_settings'))->toBeFalse('use_instance_email_settings should not be fillable (migrated to EmailNotificationSettings)'); + expect($team->isFillable('resend_api_key'))->toBeFalse('resend_api_key should not be fillable (migrated to EmailNotificationSettings)'); }); - test('Service model blocks mass assignment of identity fields', function () { - $service = new Service; + test('Team model allows mass assignment of expected fields', function () { + $team = new Team; - expect($service->isFillable('id'))->toBeFalse(); - expect($service->isFillable('uuid'))->toBeFalse(); + expect($team->isFillable('name'))->toBeTrue(); + expect($team->isFillable('description'))->toBeTrue(); + expect($team->isFillable('personal_team'))->toBeTrue(); + expect($team->isFillable('show_boarding'))->toBeTrue(); + expect($team->isFillable('custom_server_limit'))->toBeTrue(); }); - test('Service model allows mass assignment of relationship fields needed for create()', function () { - $service = new Service; - - expect($service->isFillable('environment_id'))->toBeTrue(); - expect($service->isFillable('destination_id'))->toBeTrue(); - expect($service->isFillable('destination_type'))->toBeTrue(); - expect($service->isFillable('server_id'))->toBeTrue(); - }); - - test('standalone database models block mass assignment of identity and relationship fields', function () { + test('standalone database models block mass assignment of relationship IDs', function () { $models = [ StandalonePostgresql::class, StandaloneRedis::class, @@ -161,17 +141,12 @@ foreach ($models as $modelClass) { $model = new $modelClass; + $dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type']; - expect($model->isFillable('id')) - ->toBeFalse("{$modelClass} should not allow mass assignment of 'id'"); - expect($model->isFillable('uuid')) - ->toBeFalse("{$modelClass} should not allow mass assignment of 'uuid'"); - expect($model->isFillable('environment_id')) - ->toBeFalse("{$modelClass} should not allow mass assignment of 'environment_id'"); - expect($model->isFillable('destination_id')) - ->toBeFalse("{$modelClass} should not allow mass assignment of 'destination_id'"); - expect($model->isFillable('destination_type')) - ->toBeFalse("{$modelClass} should not allow mass assignment of 'destination_type'"); + foreach ($dangerousFields as $field) { + expect($model->isFillable($field)) + ->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'"); + } } }); @@ -193,57 +168,29 @@ expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue(); }); - test('standalone database models allow mass assignment of public_port_timeout', function () { - $models = [ - StandalonePostgresql::class, - StandaloneRedis::class, - StandaloneMysql::class, - StandaloneMariadb::class, - StandaloneMongodb::class, - StandaloneKeydb::class, - StandaloneDragonfly::class, - StandaloneClickhouse::class, - ]; + test('Application fill ignores non-fillable fields', function () { + $application = new Application; + $application->fill([ + 'name' => 'test-app', + 'environment_id' => 999, + 'destination_id' => 999, + 'team_id' => 999, + 'private_key_id' => 999, + ]); - foreach ($models as $modelClass) { - $model = new $modelClass; - expect($model->isFillable('public_port_timeout')) - ->toBeTrue("{$modelClass} should allow mass assignment of 'public_port_timeout'"); - } + expect($application->name)->toBe('test-app'); + expect($application->environment_id)->toBeNull(); + expect($application->destination_id)->toBeNull(); + expect($application->private_key_id)->toBeNull(); }); - test('standalone database models allow mass assignment of SSL fields where applicable', function () { - // Models with enable_ssl - $sslModels = [ - StandalonePostgresql::class, - StandaloneMysql::class, - StandaloneMariadb::class, - StandaloneMongodb::class, - StandaloneRedis::class, - StandaloneKeydb::class, - StandaloneDragonfly::class, - ]; + test('Service model blocks mass assignment of relationship IDs', function () { + $service = new Service; - foreach ($sslModels as $modelClass) { - $model = new $modelClass; - expect($model->isFillable('enable_ssl')) - ->toBeTrue("{$modelClass} should allow mass assignment of 'enable_ssl'"); - } - - // Clickhouse has no SSL columns - expect((new StandaloneClickhouse)->isFillable('enable_ssl'))->toBeFalse(); - - // Models with ssl_mode - $sslModeModels = [ - StandalonePostgresql::class, - StandaloneMysql::class, - StandaloneMongodb::class, - ]; - - foreach ($sslModeModels as $modelClass) { - $model = new $modelClass; - expect($model->isFillable('ssl_mode')) - ->toBeTrue("{$modelClass} should allow mass assignment of 'ssl_mode'"); - } + expect($service->isFillable('id'))->toBeFalse(); + expect($service->isFillable('uuid'))->toBeFalse(); + expect($service->isFillable('environment_id'))->toBeFalse(); + expect($service->isFillable('destination_id'))->toBeFalse(); + expect($service->isFillable('server_id'))->toBeFalse(); }); });