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.
This commit is contained in:
Andras Bacsai 2026-03-28 12:32:57 +01:00
parent 48ba4ece3c
commit 67a4fcc2ab
20 changed files with 593 additions and 95 deletions

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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,

View file

@ -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(),

View file

@ -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,

View file

@ -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})<br><br>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})<br><br>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})<br><br>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})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}

View file

@ -265,8 +265,6 @@ public static function flushIdentityMap(): void
'server_metadata',
];
protected $guarded = [];
use HasSafeStringAttribute;
public function type()

View file

@ -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);

View file

@ -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'];

View file

@ -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'];

View file

@ -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'];

View file

@ -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'];

View file

@ -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'];

View file

@ -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'];

View file

@ -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'];

View file

@ -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'];

View file

@ -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',

View file

@ -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

View file

@ -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',

View file

@ -0,0 +1,182 @@
<?php
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
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\Team;
use App\Models\User;
describe('mass assignment protection', function () {
test('no API-exposed model uses unguarded $guarded = []', function () {
$models = [
Application::class,
Service::class,
User::class,
Team::class,
Server::class,
StandalonePostgresql::class,
StandaloneRedis::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
];
foreach ($models as $modelClass) {
$model = new $modelClass;
$guarded = $model->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();
});
});