From 48ba4ece3c1b43cb4b9627438c0ff4e4251e3511 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:28:54 +0100 Subject: [PATCH 1/3] fix: harden GetLogs Livewire component with locked properties and input validation Add #[Locked] attributes to security-sensitive properties (resource, servicesubtype, server, container) to prevent client-side modification via Livewire wire protocol. Add container name validation using ValidationPatterns::isValidContainerName() and server ownership authorization via Server::ownedByCurrentTeam() in both getLogs() and downloadAllLogs() methods. Co-Authored-By: Claude Opus 4.6 --- app/Livewire/Project/Shared/GetLogs.php | 32 ++++- tests/Feature/GetLogsCommandInjectionTest.php | 110 ++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/GetLogsCommandInjectionTest.php 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/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php new file mode 100644 index 000000000..34824b48b --- /dev/null +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -0,0 +1,110 @@ +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 container name validation in getLogs', function () { + test('getLogs method validates container name with ValidationPatterns', function () { + $method = new ReflectionMethod(GetLogs::class, 'getLogs'); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('ValidationPatterns::isValidContainerName'); + }); + + test('downloadAllLogs method validates container name with ValidationPatterns', function () { + $method = new ReflectionMethod(GetLogs::class, 'downloadAllLogs'); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('ValidationPatterns::isValidContainerName'); + }); +}); + +describe('GetLogs authorization checks', function () { + test('getLogs method checks server ownership via ownedByCurrentTeam', function () { + $method = new ReflectionMethod(GetLogs::class, 'getLogs'); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('Server::ownedByCurrentTeam()'); + }); + + test('downloadAllLogs method checks server ownership via ownedByCurrentTeam', function () { + $method = new ReflectionMethod(GetLogs::class, 'downloadAllLogs'); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); + $methodBody = implode('', $lines); + + expect($methodBody)->toContain('Server::ownedByCurrentTeam()'); + }); +}); + +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(); + }); +}); From 67a4fcc2ab8134f905f32fab8057b3c11e18fbb2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:32:57 +0100 Subject: [PATCH 2/3] fix: add mass assignment protection to models Replace $guarded = [] with explicit $fillable whitelists across all models. Update controllers to use request->only($allowedFields) when assigning request data. Switch Livewire components to forceFill() for explicit mass assignment. Add integration tests for mass assignment protection. --- .../Api/ApplicationsController.php | 14 +- .../Controllers/Api/DatabasesController.php | 16 +- .../Controllers/Api/SecurityController.php | 7 +- app/Livewire/Project/CloneMe.php | 34 ++-- .../Project/Shared/ResourceOperations.php | 59 +++--- app/Models/Application.php | 109 +++++++++-- app/Models/Server.php | 2 - app/Models/Service.php | 14 +- app/Models/StandaloneClickhouse.php | 26 ++- app/Models/StandaloneDragonfly.php | 25 ++- app/Models/StandaloneKeydb.php | 26 ++- app/Models/StandaloneMariadb.php | 27 ++- app/Models/StandaloneMongodb.php | 26 ++- app/Models/StandaloneMysql.php | 27 ++- app/Models/StandalonePostgresql.php | 29 ++- app/Models/StandaloneRedis.php | 24 ++- app/Models/Team.php | 9 +- app/Models/User.php | 19 +- bootstrap/helpers/applications.php | 13 +- .../Feature/MassAssignmentProtectionTest.php | 182 ++++++++++++++++++ 20 files changed, 593 insertions(+), 95 deletions(-) create mode 100644 tests/Feature/MassAssignmentProtectionTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ad1f50ea2..82d662177 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1158,7 +1158,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { $dockerComposeDomains = collect($request->docker_compose_domains); @@ -1385,7 +1385,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { @@ -1585,7 +1585,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $dockerComposeDomainsJson = collect(); if ($request->has('docker_compose_domains')) { @@ -1772,7 +1772,7 @@ private function create_application(Request $request, $type) } $application = new Application; - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $application->fqdn = $fqdn; $application->ports_exposes = $port; $application->build_pack = 'dockerfile'; @@ -1884,7 +1884,7 @@ private function create_application(Request $request, $type) $application = new Application; removeUnnecessaryFieldsFromRequest($request); - $application->fill($request->all()); + $application->fill($request->only($allowedFields)); $application->fqdn = $fqdn; $application->build_pack = 'dockerimage'; $application->destination_id = $destination->id; @@ -2000,7 +2000,7 @@ private function create_application(Request $request, $type) $service = new Service; removeUnnecessaryFieldsFromRequest($request); - $service->fill($request->all()); + $service->fill($request->only($allowedFields)); $service->docker_compose_raw = $dockerComposeRaw; $service->environment_id = $environment->id; @@ -2760,7 +2760,7 @@ public function update_by_uuid(Request $request) removeUnnecessaryFieldsFromRequest($request); - $data = $request->all(); + $data = $request->only($allowedFields); if ($requestHasDomains && $server->isProxyShouldRun()) { data_set($data, 'fqdn', $domains); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 660ed4529..3fd1b8db8 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1740,7 +1740,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('postgres_conf', $postgresConf); } - $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1795,7 +1795,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mariadb_conf', $mariadbConf); } - $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1854,7 +1854,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mysql_conf', $mysqlConf); } - $database = create_standalone_mysql($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1910,7 +1910,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('redis_conf', $redisConf); } - $database = create_standalone_redis($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1947,7 +1947,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -1996,7 +1996,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('keydb_conf', $keydbConf); } - $database = create_standalone_keydb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2032,7 +2032,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) ], 422); } removeUnnecessaryFieldsFromRequest($request); - $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } @@ -2090,7 +2090,7 @@ public function create_database(Request $request, NewDatabaseTypes $type) } $request->offsetSet('mongo_conf', $mongoConf); } - $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->all()); + $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields)); if ($instantDeploy) { StartDatabase::dispatch($database); } diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e7b36cb9a..2c62928c2 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\PrivateKey; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -176,7 +177,7 @@ public function create_key(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } $validator = customApiValidator($request->all(), [ @@ -300,7 +301,7 @@ public function update_key(Request $request) return invalidTokenResponse(); } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -330,7 +331,7 @@ public function update_key(Request $request) 'message' => 'Private Key not found.', ], 404); } - $foundKey->update($request->all()); + $foundKey->update($request->only($allowedFields)); return response()->json(serializeApiResponse([ 'uuid' => $foundKey->uuid, diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 3b3e42619..3b04c3b7f 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -139,7 +139,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'status' => 'exited', 'started_at' => null, @@ -187,7 +187,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $newDatabase->id, ]); @@ -216,7 +216,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $newDatabase->id, ]); $newStorage->save(); @@ -229,7 +229,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'database_id' => $newDatabase->id, 'database_type' => $newDatabase->getMorphClass(), @@ -247,7 +247,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill($payload); + ])->forceFill($payload); $newEnvironmentVariable->save(); } } @@ -258,7 +258,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'environment_id' => $environment->id, 'destination_id' => $this->selectedDestination, @@ -276,7 +276,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => (string) new Cuid2, 'service_id' => $newService->id, 'team_id' => currentTeam()->id, @@ -290,7 +290,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resourceable_id' => $newService->id, 'resourceable_type' => $newService->getMorphClass(), ]); @@ -298,9 +298,9 @@ public function clone(string $type) } foreach ($newService->applications() as $application) { - $application->update([ + $application->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -315,7 +315,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $application->id, ]); @@ -344,7 +344,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $application->id, ]); $newStorage->save(); @@ -352,9 +352,9 @@ public function clone(string $type) } foreach ($newService->databases() as $database) { - $database->update([ + $database->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -369,7 +369,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $database->id, ]); @@ -398,7 +398,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $database->id, ]); $newStorage->save(); @@ -411,7 +411,7 @@ public function clone(string $type) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index e769e4bcb..a26b43026 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -7,9 +7,18 @@ use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Jobs\VolumeCloneJob; +use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -60,7 +69,7 @@ public function cloneTo($destination_id) $uuid = (string) new Cuid2; $server = $new_destination->server; - if ($this->resource->getMorphClass() === \App\Models\Application::class) { + if ($this->resource->getMorphClass() === Application::class) { $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); $route = route('project.application.configuration', [ @@ -71,21 +80,21 @@ public function cloneTo($destination_id) return redirect()->to($route); } elseif ( - $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $this->resource->getMorphClass() === StandalonePostgresql::class || + $this->resource->getMorphClass() === StandaloneMongodb::class || + $this->resource->getMorphClass() === StandaloneMysql::class || + $this->resource->getMorphClass() === StandaloneMariadb::class || + $this->resource->getMorphClass() === StandaloneRedis::class || + $this->resource->getMorphClass() === StandaloneKeydb::class || + $this->resource->getMorphClass() === StandaloneDragonfly::class || + $this->resource->getMorphClass() === StandaloneClickhouse::class ) { $uuid = (string) new Cuid2; $new_resource = $this->resource->replicate([ 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'name' => $this->resource->name.'-clone-'.$uuid, 'status' => 'exited', @@ -133,7 +142,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $new_resource->id, ]); @@ -162,7 +171,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $new_resource->id, ]); $newStorage->save(); @@ -175,7 +184,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'database_id' => $new_resource->id, 'database_type' => $new_resource->getMorphClass(), @@ -194,7 +203,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill($payload); + ])->forceFill($payload); $newEnvironmentVariable->save(); } @@ -211,7 +220,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => $uuid, 'name' => $this->resource->name.'-clone-'.$uuid, 'destination_id' => $new_destination->id, @@ -232,7 +241,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => (string) new Cuid2, 'service_id' => $new_resource->id, 'team_id' => currentTeam()->id, @@ -246,7 +255,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resourceable_id' => $new_resource->id, 'resourceable_type' => $new_resource->getMorphClass(), ]); @@ -254,9 +263,9 @@ public function cloneTo($destination_id) } foreach ($new_resource->applications() as $application) { - $application->update([ + $application->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $application->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -271,7 +280,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $application->id, ]); @@ -296,9 +305,9 @@ public function cloneTo($destination_id) } foreach ($new_resource->databases() as $database) { - $database->update([ + $database->forceFill([ 'status' => 'exited', - ]); + ])->save(); $persistentVolumes = $database->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { @@ -313,7 +322,7 @@ public function cloneTo($destination_id) 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $database->id, ]); @@ -354,9 +363,9 @@ public function moveTo($environment_id) try { $this->authorize('update', $this->resource); $new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id); - $this->resource->update([ + $this->resource->forceFill([ 'environment_id' => $environment_id, - ]); + ])->save(); if ($this->resource->type() === 'application') { $route = route('project.application.configuration', [ 'project_uuid' => $new_environment->project->uuid, diff --git a/app/Models/Application.php b/app/Models/Application.php index c446052b3..a4789ae4a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -118,7 +118,92 @@ class Application extends BaseModel private static $parserVersion = '5'; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'fqdn', + 'git_repository', + 'git_branch', + 'git_commit_sha', + 'git_full_url', + 'docker_registry_image_name', + 'docker_registry_image_tag', + 'build_pack', + 'static_image', + 'install_command', + 'build_command', + 'start_command', + 'ports_exposes', + 'ports_mappings', + 'base_directory', + 'publish_directory', + 'health_check_enabled', + 'health_check_path', + 'health_check_port', + 'health_check_host', + 'health_check_method', + 'health_check_return_code', + 'health_check_scheme', + 'health_check_response_text', + 'health_check_interval', + 'health_check_timeout', + 'health_check_retries', + 'health_check_start_period', + 'health_check_type', + 'health_check_command', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'status', + 'preview_url_template', + 'dockerfile', + 'dockerfile_location', + 'dockerfile_target_build', + 'custom_labels', + 'custom_docker_run_options', + 'post_deployment_command', + 'post_deployment_command_container', + 'pre_deployment_command', + 'pre_deployment_command_container', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + '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', + 'swarm_replicas', + 'swarm_placement_constraints', + 'watch_paths', + 'redirect', + 'compose_parsing_version', + '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', + ]; protected $appends = ['server_status']; @@ -1145,7 +1230,7 @@ public function getGitRemoteStatus(string $deployment_uuid) 'is_accessible' => true, 'error' => null, ]; - } catch (\RuntimeException $ex) { + } catch (RuntimeException $ex) { return [ 'is_accessible' => false, 'error' => $ex->getMessage(), @@ -1202,7 +1287,7 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_ ]; } - if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; $private_key = data_get($gitlabSource, 'privateKey.private_key'); @@ -1354,7 +1439,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req $source_html_url_host = $url['host']; $source_html_url_scheme = $url['scheme']; - if ($this->source->getMorphClass() === \App\Models\GithubApp::class) { + if ($this->source->getMorphClass() === GithubApp::class) { if ($this->source->is_public) { $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}"); @@ -1409,7 +1494,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req ]; } - if ($this->source->getMorphClass() === \App\Models\GitlabApp::class) { + if ($this->source->getMorphClass() === GitlabApp::class) { $gitlabSource = $this->source; $private_key = data_get($gitlabSource, 'privateKey.private_key'); @@ -1600,7 +1685,7 @@ public function oldRawParser() try { $yaml = Yaml::parse($this->docker_compose_raw); } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + throw new RuntimeException($e->getMessage()); } $services = data_get($yaml, 'services'); @@ -1682,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:\n\n{$gitRemoteStatus['error']}"); } $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); $gitVersion = str($getGitVersion)->explode(' ')->last(); @@ -1732,15 +1817,15 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->save(); if (str($e->getMessage())->contains('No such file')) { - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) { if ($this->deploymentType() === 'deploy_key') { - throw new \RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.'); + throw new RuntimeException('Your deploy key does not have access to the repository. Please check your deploy key and try again.'); } - throw new \RuntimeException('Repository does not exist. Please check your repository URL and try again.'); + throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } - throw new \RuntimeException($e->getMessage()); + throw new RuntimeException($e->getMessage()); } finally { // Cleanup only - restoration happens in catch block $commands = collect([ @@ -1793,7 +1878,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = $this->base_directory = $initialBaseDirectory; $this->save(); - throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); + throw new RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})

Check if you used the right extension (.yaml or .yml) in the compose file name."); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 9237763c8..b3dcf6353 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -265,8 +265,6 @@ public static function flushIdentityMap(): void 'server_metadata', ]; - protected $guarded = []; - use HasSafeStringAttribute; public function type() diff --git a/app/Models/Service.php b/app/Models/Service.php index 84c047bb7..b3ff85e53 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -15,6 +15,7 @@ use OpenApi\Attributes as OA; use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; +use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; #[OA\Schema( @@ -47,7 +48,16 @@ class Service extends BaseModel private static $parserVersion = '5'; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'docker_compose_raw', + 'docker_compose', + 'connect_to_docker_network', + 'service_type', + 'config_hash', + 'compose_parsing_version', + ]; protected $appends = ['server_status', 'status']; @@ -1552,7 +1562,7 @@ public function saveComposeConfigs() // Generate SERVICE_NAME_* environment variables from docker-compose services if ($this->docker_compose) { try { - $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $dockerCompose = Yaml::parse($this->docker_compose); $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 143aadb6a..74382d87c 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -13,7 +13,31 @@ class StandaloneClickhouse extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'clickhouse_admin_user', + 'clickhouse_admin_password', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index c823c305b..7cc74f0ce 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -13,7 +13,30 @@ class StandaloneDragonfly extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'dragonfly_password', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; 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 f286e8538..7a0d7f03d 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -13,7 +13,31 @@ class StandaloneKeydb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'keydb_password', + 'keydb_conf', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index efa62353c..6cac9e5f4 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -14,7 +14,32 @@ class StandaloneMariadb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'mariadb_root_password', + 'mariadb_user', + 'mariadb_password', + 'mariadb_database', + 'mariadb_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; 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 9418ebc21..5ca4ef5d3 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -13,7 +13,31 @@ class StandaloneMongodb extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'mongo_conf', + 'mongo_initdb_root_username', + 'mongo_initdb_root_password', + 'mongo_initdb_database', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; 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 2b7e9f2b6..cf8d78a9c 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -13,7 +13,32 @@ class StandaloneMysql extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'mysql_root_password', + 'mysql_user', + 'mysql_password', + 'mysql_database', + 'mysql_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; 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 cea600236..7db334c5d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -13,7 +13,34 @@ class StandalonePostgresql extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'postgres_user', + 'postgres_password', + 'postgres_db', + 'postgres_initdb_args', + 'postgres_host_auth_method', + 'postgres_conf', + 'init_scripts', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; 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 0e904ab31..812a0e5cb 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -13,7 +13,29 @@ class StandaloneRedis extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'redis_password', + 'redis_conf', + 'status', + 'image', + 'is_public', + 'public_port', + 'ports_mappings', + 'limits_memory', + 'limits_memory_swap', + 'limits_memory_swappiness', + 'limits_memory_reservation', + 'limits_cpus', + 'limits_cpuset', + 'limits_cpu_shares', + 'started_at', + 'restart_count', + 'last_restart_at', + 'last_restart_type', + 'last_online_at', + ]; 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 5a7b377b6..4b9751706 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -40,7 +40,14 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen { use HasFactory, HasNotificationSettings, HasSafeStringAttribute, Notifiable; - protected $guarded = []; + protected $fillable = [ + 'name', + 'description', + 'show_boarding', + 'custom_server_limit', + 'use_instance_email_settings', + 'resend_api_key', + ]; protected $casts = [ 'personal_team' => 'boolean', diff --git a/app/Models/User.php b/app/Models/User.php index 4561cddb2..6b6f93239 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,7 +4,9 @@ use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; +use App\Notifications\TransactionalEmails\EmailChangeVerification; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; +use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,7 +43,16 @@ class User extends Authenticatable implements SendsEmail { use DeletesUserSessions, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; - protected $guarded = []; + protected $fillable = [ + 'name', + 'email', + 'password', + 'force_password_reset', + 'marketing_emails', + 'pending_email', + 'email_change_code', + 'email_change_code_expires_at', + ]; protected $hidden = [ 'password', @@ -228,7 +239,7 @@ public function changelogReads() public function getUnreadChangelogCount(): int { - return app(\App\Services\ChangelogService::class)->getUnreadCountForUser($this); + return app(ChangelogService::class)->getUnreadCountForUser($this); } public function getRecipients(): array @@ -239,7 +250,7 @@ public function getRecipients(): array public function sendVerificationEmail() { $mail = new MailMessage; - $url = Url::temporarySignedRoute( + $url = URL::temporarySignedRoute( 'verify.verify', Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), [ @@ -408,7 +419,7 @@ public function requestEmailChange(string $newEmail): void ]); // Send verification email to new address - $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt)); + $this->notify(new EmailChangeVerification($this, $code, $newEmail, $expiresAt)); } public function isEmailChangeCodeValid(string $code): bool diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index c522cd0ca..fbcedf277 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -6,6 +6,7 @@ use App\Jobs\VolumeCloneJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\EnvironmentVariable; use App\Models\Server; use App\Models\StandaloneDocker; use Spatie\Url\Url; @@ -192,7 +193,7 @@ function clone_application(Application $source, $destination, array $overrides = $server = $destination->server; if ($server->team_id !== currentTeam()->id) { - throw new \RuntimeException('Destination does not belong to the current team.'); + throw new RuntimeException('Destination does not belong to the current team.'); } // Prepare name and URL @@ -211,7 +212,7 @@ function clone_application(Application $source, $destination, array $overrides = 'updated_at', 'additional_servers_count', 'additional_networks_count', - ])->fill(array_merge([ + ])->forceFill(array_merge([ 'uuid' => $uuid, 'name' => $name, 'fqdn' => $url, @@ -322,8 +323,8 @@ function clone_application(Application $source, $destination, array $overrides = destination: $source->destination, no_questions_asked: true ); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } catch (Exception $e) { + Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } } } @@ -344,7 +345,7 @@ function clone_application(Application $source, $destination, array $overrides = // Clone production environment variables without triggering the created hook $environmentVariables = $source->environment_variables()->get(); foreach ($environmentVariables as $environmentVariable) { - \App\Models\EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { + EnvironmentVariable::withoutEvents(function () use ($environmentVariable, $newApplication) { $newEnvironmentVariable = $environmentVariable->replicate([ 'id', 'created_at', @@ -361,7 +362,7 @@ function clone_application(Application $source, $destination, array $overrides = // Clone preview environment variables $previewEnvironmentVariables = $source->environment_variables_preview()->get(); foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) { - \App\Models\EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { + EnvironmentVariable::withoutEvents(function () use ($previewEnvironmentVariable, $newApplication) { $newPreviewEnvironmentVariable = $previewEnvironmentVariable->replicate([ 'id', 'created_at', diff --git a/tests/Feature/MassAssignmentProtectionTest.php b/tests/Feature/MassAssignmentProtectionTest.php new file mode 100644 index 000000000..f6518648f --- /dev/null +++ b/tests/Feature/MassAssignmentProtectionTest.php @@ -0,0 +1,182 @@ +getGuarded(); + $fillable = $model->getFillable(); + + // Model must NOT have $guarded = [] (empty guard = no protection) + // It should either have non-empty $guarded OR non-empty $fillable + $hasProtection = $guarded !== ['*'] ? count($guarded) > 0 : true; + $hasProtection = $hasProtection || count($fillable) > 0; + + expect($hasProtection) + ->toBeTrue("Model {$modelClass} has no mass assignment protection (empty \$guarded and empty \$fillable)"); + } + }); + + 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']; + + 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', 'limits_memory', 'status']; + + foreach ($userFields as $field) { + expect($application->isFillable($field)) + ->toBeTrue("Application model should allow mass assignment of '{$field}'"); + } + }); + + 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(); + }); + + test('User model blocks mass assignment of auth-sensitive fields', function () { + $user = new User; + + expect($user->isFillable('id'))->toBeFalse('User id should not be fillable'); + expect($user->isFillable('email_verified_at'))->toBeFalse('email_verified_at should not be fillable'); + 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'); + }); + + test('User model allows mass assignment of profile fields', function () { + $user = new User; + + expect($user->isFillable('name'))->toBeTrue(); + expect($user->isFillable('email'))->toBeTrue(); + expect($user->isFillable('password'))->toBeTrue(); + }); + + test('Team model blocks mass assignment of internal fields', function () { + $team = new Team; + + expect($team->isFillable('id'))->toBeFalse(); + expect($team->isFillable('personal_team'))->toBeFalse('personal_team should not be fillable'); + }); + + test('standalone database models block mass assignment of relationship IDs', function () { + $models = [ + StandalonePostgresql::class, + StandaloneRedis::class, + StandaloneMysql::class, + StandaloneMariadb::class, + StandaloneMongodb::class, + StandaloneKeydb::class, + StandaloneDragonfly::class, + StandaloneClickhouse::class, + ]; + + foreach ($models as $modelClass) { + $model = new $modelClass; + $dangerousFields = ['id', 'uuid', 'environment_id', 'destination_id', 'destination_type']; + + foreach ($dangerousFields as $field) { + expect($model->isFillable($field)) + ->toBeFalse("Model {$modelClass} should not allow mass assignment of '{$field}'"); + } + } + }); + + test('standalone database models allow mass assignment of config fields', function () { + $model = new StandalonePostgresql; + expect($model->isFillable('name'))->toBeTrue(); + expect($model->isFillable('postgres_user'))->toBeTrue(); + expect($model->isFillable('postgres_password'))->toBeTrue(); + expect($model->isFillable('image'))->toBeTrue(); + expect($model->isFillable('limits_memory'))->toBeTrue(); + + $model = new StandaloneRedis; + expect($model->isFillable('redis_password'))->toBeTrue(); + + $model = new StandaloneMysql; + expect($model->isFillable('mysql_root_password'))->toBeTrue(); + + $model = new StandaloneMongodb; + expect($model->isFillable('mongo_initdb_root_username'))->toBeTrue(); + }); + + 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, + ]); + + expect($application->name)->toBe('test-app'); + expect($application->environment_id)->toBeNull(); + expect($application->destination_id)->toBeNull(); + expect($application->private_key_id)->toBeNull(); + }); + + test('Service model blocks mass assignment of relationship IDs', function () { + $service = new Service; + + 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(); + }); +}); From b3256d4df14e21c6d8936d972f45cbc47e07cca4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:56:04 +0200 Subject: [PATCH 3/3] fix(security): harden model assignment and sensitive data handling Restrict mass-assignable attributes across user/team/redis models and switch privileged root/team creation paths to forceFill/forceCreate. Encrypt legacy ClickHouse admin passwords via migration and cast the correct ClickHouse password field as encrypted. Tighten API and runtime exposure by removing sensitive team fields from responses and sanitizing Git/compose error messages. Expand security-focused feature coverage for command-injection and mass assignment protections. --- app/Actions/Fortify/CreateNewUser.php | 3 +- app/Http/Controllers/Api/TeamController.php | 8 -- app/Models/Application.php | 4 +- app/Models/ServerSetting.php | 1 + app/Models/StandaloneClickhouse.php | 2 +- app/Models/StandaloneRedis.php | 1 - app/Models/Team.php | 3 +- app/Models/User.php | 11 +- ...pt_existing_clickhouse_admin_passwords.php | 39 ++++++ database/seeders/RootUserSeeder.php | 3 +- tests/Feature/GetLogsCommandInjectionTest.php | 120 +++++++++++++----- .../Feature/MassAssignmentProtectionTest.php | 18 ++- 12 files changed, 154 insertions(+), 59 deletions(-) create mode 100644 database/migrations/2026_03_29_000000_encrypt_existing_clickhouse_admin_passwords.php 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/Models/Application.php b/app/Models/Application.php index a4789ae4a..3312f4c76 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1767,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(); @@ -1825,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 504cfa60a..efc7bc8de 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -56,6 +56,7 @@ class ServerSetting extends Model protected $guarded = []; protected $casts = [ + 'force_disabled' => 'boolean', 'force_docker_cleanup' => 'boolean', 'docker_cleanup_threshold' => 'integer', 'sentinel_token' => 'encrypted', diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 74382d87c..c192e5360 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -42,7 +42,7 @@ class StandaloneClickhouse extends BaseModel 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/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 812a0e5cb..2320619cf 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -16,7 +16,6 @@ class StandaloneRedis extends BaseModel protected $fillable = [ 'name', 'description', - 'redis_password', 'redis_conf', 'status', 'image', diff --git a/app/Models/Team.php b/app/Models/Team.php index 4b9751706..8eb8fa050 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -43,10 +43,9 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen protected $fillable = [ 'name', 'description', + 'personal_team', 'show_boarding', 'custom_server_limit', - 'use_instance_email_settings', - 'resend_api_key', ]; protected $casts = [ diff --git a/app/Models/User.php b/app/Models/User.php index 6b6f93239..a62cb8358 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 index 34824b48b..3e5a33b66 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -1,8 +1,40 @@ 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 () { @@ -34,47 +66,67 @@ }); }); -describe('GetLogs container name validation in getLogs', function () { - test('getLogs method validates container name with ValidationPatterns', function () { - $method = new ReflectionMethod(GetLogs::class, 'getLogs'); - $startLine = $method->getStartLine(); - $endLine = $method->getEndLine(); - $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); - $methodBody = implode('', $lines); +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); - expect($methodBody)->toContain('ValidationPatterns::isValidContainerName'); + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'container;malicious-command', + ]) + ->call('getLogs') + ->assertSet('outputs', 'Invalid container name.'); }); - test('downloadAllLogs method validates container name with ValidationPatterns', function () { - $method = new ReflectionMethod(GetLogs::class, 'downloadAllLogs'); - $startLine = $method->getStartLine(); - $endLine = $method->getEndLine(); - $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); - $methodBody = implode('', $lines); + test('getLogs rejects unauthorized server access', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); - expect($methodBody)->toContain('ValidationPatterns::isValidContainerName'); - }); -}); - -describe('GetLogs authorization checks', function () { - test('getLogs method checks server ownership via ownedByCurrentTeam', function () { - $method = new ReflectionMethod(GetLogs::class, 'getLogs'); - $startLine = $method->getStartLine(); - $endLine = $method->getEndLine(); - $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); - $methodBody = implode('', $lines); - - expect($methodBody)->toContain('Server::ownedByCurrentTeam()'); + Livewire::test(GetLogs::class, [ + 'server' => $otherServer, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->call('getLogs') + ->assertSet('outputs', 'Unauthorized.'); }); - test('downloadAllLogs method checks server ownership via ownedByCurrentTeam', function () { - $method = new ReflectionMethod(GetLogs::class, 'downloadAllLogs'); - $startLine = $method->getStartLine(); - $endLine = $method->getEndLine(); - $lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1); - $methodBody = implode('', $lines); + 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); - expect($methodBody)->toContain('Server::ownedByCurrentTeam()'); + 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(''); }); }); diff --git a/tests/Feature/MassAssignmentProtectionTest.php b/tests/Feature/MassAssignmentProtectionTest.php index f6518648f..18de67ce7 100644 --- a/tests/Feature/MassAssignmentProtectionTest.php +++ b/tests/Feature/MassAssignmentProtectionTest.php @@ -96,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 () { @@ -110,7 +113,18 @@ $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('Team model allows mass assignment of expected fields', function () { + $team = new Team; + + 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('standalone database models block mass assignment of relationship IDs', function () { @@ -145,7 +159,7 @@ expect($model->isFillable('limits_memory'))->toBeTrue(); $model = new StandaloneRedis; - expect($model->isFillable('redis_password'))->toBeTrue(); + expect($model->isFillable('redis_conf'))->toBeTrue(); $model = new StandaloneMysql; expect($model->isFillable('mysql_root_password'))->toBeTrue();